Each user profile should have its own epoch origin

Bug: 229091766
Test: atest AdServicesServiceCoreUnitTests --iterations 2

Change-Id: I500e22c2c7449ca9cb062f408f7df0346bfef575
diff --git a/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java b/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
index 2eb7296..ceb6843 100644
--- a/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
+++ b/adservices/service-core/java/com/android/adservices/data/topics/TopicsDao.java
@@ -51,7 +51,8 @@
         TopicsTables.CallerCanLearnTopicsContract.TABLE,
         TopicsTables.ReturnedTopicContract.TABLE,
         TopicsTables.TopTopicsContract.TABLE,
-        TopicsTables.BlockedTopicsContract.TABLE
+        TopicsTables.BlockedTopicsContract.TABLE,
+        TopicsTables.EpochOriginContract.TABLE,
     };
 
     private final DbHelper mDbHelper; // Used in tests.
@@ -963,4 +964,66 @@
             LogUtil.e(e, String.format("Failed to delete %s in table %s.", appNames, tableName));
         }
     }
+
+    /**
+     * Persist the origin's timestamp of epoch service in milliseconds into database.
+     *
+     * @param originTimestampMs the timestamp user first calls Topics API
+     */
+    public void persistEpochOrigin(long originTimestampMs) {
+        SQLiteDatabase db = mDbHelper.safeGetWritableDatabase();
+        if (db == null) {
+            return;
+        }
+
+        ContentValues values = new ContentValues();
+        values.put(TopicsTables.EpochOriginContract.ORIGIN, originTimestampMs);
+
+        try {
+            db.insert(TopicsTables.EpochOriginContract.TABLE, /* nullColumnHack */ null, values);
+        } catch (SQLException e) {
+            LogUtil.e("Failed to persist epoch origin." + e.getMessage());
+        }
+    }
+
+    /**
+     * Retrieve origin's timestamp of epoch service in milliseconds. If there is no origin persisted
+     * in database, return -1;
+     *
+     * @return the origin's timestamp of epoch service in milliseconds. Return -1 if no origin is
+     *     persisted.
+     */
+    public long retrieveEpochOrigin() {
+        long origin = -1L;
+
+        SQLiteDatabase db = mDbHelper.safeGetReadableDatabase();
+        if (db == null) {
+            return origin;
+        }
+
+        String[] projection = {
+            TopicsTables.EpochOriginContract.ORIGIN,
+        };
+
+        try (Cursor cursor =
+                db.query(
+                        TopicsTables.EpochOriginContract.TABLE, // The table to query
+                        projection, // The array of columns to return (pass null to get all)
+                        null, // The columns for the WHERE clause
+                        null, // The values for the WHERE clause
+                        null, // don't group the rows
+                        null, // don't filter by row groups
+                        null // The sort order
+                        )) {
+            // Return the only entry in this table if existed.
+            if (cursor.moveToNext()) {
+                origin =
+                        cursor.getLong(
+                                cursor.getColumnIndexOrThrow(
+                                        TopicsTables.EpochOriginContract.ORIGIN));
+            }
+        }
+
+        return origin;
+    }
 }
diff --git a/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java b/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
index 86ae81d..0203095 100644
--- a/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
+++ b/adservices/service-core/java/com/android/adservices/data/topics/TopicsTables.java
@@ -281,6 +281,33 @@
                     + " INTEGER NOT NULL"
                     + ")";
 
+    /**
+     * Table to store the original timestamp when the user calls Topics API. This table should have
+     * only 1 row that stores the origin.
+     */
+    public interface EpochOriginContract {
+        String TABLE = TOPICS_TABLE_PREFIX + "epoch_origin";
+        String ONE_ROW_CHECK = "one_row_check"; // to constrain 1 origin
+        String ORIGIN = "origin";
+    }
+
+    // At the first time inserting a record, it won't persist one_row_check field so that this first
+    // entry will have one_row_check = 1. Therefore, further persisting is not allowed as primary
+    // key cannot be duplicated value and one_row_check is constrained to only equal to 1 to forbid
+    // any increment.
+    private static final String CREATE_TABLE_EPOCH_ORIGIN =
+            "CREATE TABLE "
+                    + EpochOriginContract.TABLE
+                    + "("
+                    + EpochOriginContract.ONE_ROW_CHECK
+                    + " INTEGER PRIMARY KEY DEFAULT 1, "
+                    + EpochOriginContract.ORIGIN
+                    + " INTEGER NOT NULL, "
+                    + "CONSTRAINT one_row_constraint CHECK ("
+                    + EpochOriginContract.ONE_ROW_CHECK
+                    + " = 1) "
+                    + ")";
+
     // Consolidated list of create statements for all tables.
     public static final List<String> CREATE_STATEMENTS =
             Collections.unmodifiableList(
@@ -292,7 +319,8 @@
                             CREATE_TABLE_USAGE_HISTORY,
                             CREATE_TABLE_APP_USAGE_HISTORY,
                             CREATE_TABLE_CALLER_CAN_LEARN_TOPICS,
-                            CREATE_TABLE_BLOCKED_TOPICS));
+                            CREATE_TABLE_BLOCKED_TOPICS,
+                            CREATE_TABLE_EPOCH_ORIGIN));
 
     // Private constructor to prevent instantiation.
     private TopicsTables() {}
diff --git a/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java b/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
index 41ba81b..5247bd5 100644
--- a/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
+++ b/adservices/service-core/java/com/android/adservices/service/topics/EpochManager.java
@@ -32,12 +32,14 @@
 import com.android.adservices.data.topics.TopicsTables;
 import com.android.adservices.service.Flags;
 import com.android.adservices.service.FlagsFactory;
+import com.android.adservices.service.stats.Clock;
 import com.android.adservices.service.topics.classifier.Classifier;
 import com.android.adservices.service.topics.classifier.OnDeviceClassifier;
 import com.android.internal.annotations.VisibleForTesting;
 import com.android.internal.util.Preconditions;
 
 import java.io.PrintWriter;
+import java.time.Instant;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -49,14 +51,6 @@
 
 /** A class to manage Epoch computation. */
 public class EpochManager implements Dumpable {
-
-    // We use this origin to compute epoch timestamp.
-    // In other words, the first epoch started at
-    // Saturday, January 1, 2022 12:00:00 AM
-    // TODO(b/221463765): get the timestamp when first access to the origin.
-    // Save in SharedPreferences or slqlite db.
-    private static final long ORIGIN_EPOCH_TIMESTAMP = 1640995200;
-
     // TODO(b/223915674): make this configurable.
     // The Top Topics will have 6 topics.
     // The first 5 topics are the Top Topics derived by ML, and the 6th is a random topic from
@@ -100,6 +94,8 @@
     private final Random mRandom;
     private final Classifier mClassifier;
     private final Flags mFlags;
+    // Use Clock.SYSTEM_CLOCK except in unit tests, which pass in a local instance of Clock to mock.
+    private final Clock mClock;
 
     @VisibleForTesting
     EpochManager(
@@ -107,12 +103,14 @@
             @NonNull DbHelper dbHelper,
             @NonNull Random random,
             @NonNull Classifier classifier,
-            Flags flags) {
+            Flags flags,
+            @NonNull Clock clock) {
         mTopicsDao = topicsDao;
         mDbHelper = dbHelper;
         mRandom = random;
         mClassifier = classifier;
         mFlags = flags;
+        mClock = clock;
     }
 
     /** Returns an instance of the EpochManager given a context. */
@@ -126,7 +124,8 @@
                                 DbHelper.getInstance(context),
                                 new Random(),
                                 OnDeviceClassifier.getInstance(context),
-                                FlagsFactory.getFlags());
+                                FlagsFactory.getFlags(),
+                                Clock.SYSTEM_CLOCK);
             }
             return sSingleton;
         }
@@ -139,10 +138,12 @@
             return;
         }
 
-        // This cross db and java boundaries multiple times so we need to have a db transaction.
+        // This cross db and java boundaries multiple times, so we need to have a db transaction.
         db.beginTransaction();
+
         long currentEpochId = getCurrentEpochId();
         LogUtil.d("EpochManager.processEpoch for the current epochId %d", currentEpochId);
+
         try {
             // Step 1: Compute the UsageMap from the UsageHistory table.
             // appSdksUsageMap = Map<App, List<SDK>> has the app and its SDKs that called Topics API
@@ -198,7 +199,7 @@
             mTopicsDao.persistTopTopics(currentEpochId, topTopics);
 
             // Step 6: Assign topics to apps and SDK from the global top topics.
-            // Currently hard-code the taxonomyVersion and the modelVersion.
+            // Currently, hard-code the taxonomyVersion and the modelVersion.
             // Return returnedAppSdkTopics = Map<Pair<App, Sdk>, Topic>
             Map<Pair<String, String>, Topic> returnedAppSdkTopics =
                     computeReturnedAppSdkTopics(callersCanLearnMap, appSdksUsageMap, topTopics);
@@ -283,13 +284,13 @@
      *     empty string.
      */
     public void recordUsageHistory(String app, String sdk) {
-        long epochID = getCurrentEpochId();
+        long epochId = getCurrentEpochId();
         // TODO(b/223159123): Do we need to filter out this log in prod build?
         LogUtil.v(
                 "EpochManager.recordUsageHistory for current EpochId = %d for %s, %s",
-                epochID, app, sdk);
-        mTopicsDao.recordUsageHistory(epochID, app, sdk);
-        mTopicsDao.recordAppUsageHistory(epochID, app);
+                epochId, app, sdk);
+        mTopicsDao.recordUsageHistory(epochId, app, sdk);
+        mTopicsDao.recordAppUsageHistory(epochId, app);
     }
 
     /**
@@ -320,6 +321,35 @@
                 && callersCanLearnMap.get(topic).contains(caller);
     }
 
+    /**
+     * Get the ID of current epoch.
+     *
+     * <p>The origin's timestamp is saved in the database. If the origin doesn't exist, it means the
+     * user never calls Topics API and the origin will be returned with -1. In this case, set
+     * current time as origin and persist it into database.
+     *
+     * @return a non-negative epoch ID of current epoch.
+     */
+    // TODO(b/237119788): Cache origin in cache manager.
+    // TODO(b/237119790): Set origin to sometime after midnight to get better maintenance timing.
+    public long getCurrentEpochId() {
+        long origin = mTopicsDao.retrieveEpochOrigin();
+        long currentTimeStamp = mClock.currentTimeMillis();
+        long epochJobPeriodsMs = mFlags.getTopicsEpochJobPeriodMs();
+
+        // If origin doesn't exist in database, set current timestamp as origin.
+        if (origin == -1) {
+            origin = currentTimeStamp;
+            mTopicsDao.persistEpochOrigin(origin);
+            LogUtil.d(
+                    "Origin isn't found! Set current time %s as origin.",
+                    Instant.ofEpochMilli(origin).toString());
+        }
+
+        LogUtil.v("Epoch length is  %d", epochJobPeriodsMs);
+        return (long) Math.floor((currentTimeStamp - origin) / (double) epochJobPeriodsMs);
+    }
+
     // Return a Map from Topic to set of App or Sdk that can learn about that topic.
     // This is similar to the table Can Learn Topic in the explainer.
     // Return Map<Topic, Set<Caller>>  where Caller = App or Sdk.
@@ -448,21 +478,6 @@
         }
     }
 
-    // Return the current epochId.
-    // Each Epoch will have an Id. The first epoch has Id = 0.
-    // For Alpha 1, we assume a fixed origin epoch starting from
-    // Saturday, January 1, 2022 12:00:00 AM.
-    // Later, we will use per device starting origin.
-    @VisibleForTesting
-    public long getCurrentEpochId() {
-        // TODO(b/221463765): Don't use a fix epoch origin like this. This is for Alpha 1 only.
-        LogUtil.v("Epoch length is  %d", mFlags.getTopicsEpochJobPeriodMs());
-        return (long)
-                Math.floor(
-                        (System.currentTimeMillis() - ORIGIN_EPOCH_TIMESTAMP)
-                                / mFlags.getTopicsEpochJobPeriodMs());
-    }
-
     @Override
     public void dump(@NonNull PrintWriter writer, @Nullable String[] args) {
         writer.println("==== EpochManager Dump ====");
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
index 5c9309f..204f876 100644
--- a/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/data/topics/TopicsDaoTest.java
@@ -71,6 +71,7 @@
         DbTestUtil.deleteTable(TopicsTables.UsageHistoryContract.TABLE);
         DbTestUtil.deleteTable(TopicsTables.AppUsageHistoryContract.TABLE);
         DbTestUtil.deleteTable(TopicsTables.BlockedTopicsContract.TABLE);
+        DbTestUtil.deleteTable(TopicsTables.EpochOriginContract.TABLE);
     }
 
     @Test
@@ -838,4 +839,37 @@
         // Nothing will happen as no satisfied entry to delete
         assertThat(topicsMapFromDb1).isEqualTo(expectedTopicsMap1);
     }
+
+    @Test
+    public void testPersistAndRetrieveEpochOrigin() {
+        final long epochOrigin = 1234567890L;
+
+        mTopicsDao.persistEpochOrigin(epochOrigin);
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin);
+    }
+
+    // TODO(b/230669931): Add test to check SQLException when it's enabled in TopicsDao.
+    @Test
+    public void testPersistAndRetrieveEpochOrigin_multipleInsertion() {
+        final long epochOrigin1 = 1L;
+        final long epochOrigin2 = 2L;
+
+        mTopicsDao.persistEpochOrigin(epochOrigin1);
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+
+        // Persist a different origin when there is an existing origin will not change the existing
+        // origin.
+        mTopicsDao.persistEpochOrigin(epochOrigin2);
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+
+        // Persist same origin
+        mTopicsDao.persistEpochOrigin(epochOrigin1);
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(epochOrigin1);
+    }
+
+    @Test
+    public void testPersistAndRetrieveEpochOrigin_EmptyTable() {
+        // Should return -1 if no origin is persisted
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(-1);
+    }
 }
diff --git a/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java b/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
index 1279ee1..c30babe 100644
--- a/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
+++ b/adservices/tests/unittest/service-core/src/com/android/adservices/service/topics/EpochManagerTest.java
@@ -19,6 +19,7 @@
 
 import static org.junit.Assert.assertThrows;
 import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
@@ -38,6 +39,7 @@
 import com.android.adservices.data.topics.TopicsTables;
 import com.android.adservices.service.Flags;
 import com.android.adservices.service.FlagsFactory;
+import com.android.adservices.service.stats.Clock;
 import com.android.adservices.service.topics.classifier.Classifier;
 
 import org.junit.Before;
@@ -66,6 +68,8 @@
 public final class EpochManagerTest {
     @SuppressWarnings({"unused"})
     private static final String TAG = "EpochManagerTest";
+
+    private static final long TOPICS_EPOCH_JOB_PERIOD_MS = 7 * 86_400_000;
     // TODO: (b/232807776) Replace below hardcoded taxonomy version and model version
     private static final long TAXONOMY_VERSION = 1L;
     private static final long MODEL_VERSION = 1L;
@@ -80,6 +84,7 @@
     private EpochManager mEpochManager;
 
     @Mock Classifier mMockClassifier;
+    @Mock Clock mMockClock;
 
     @Before
     public void setup() {
@@ -88,7 +93,8 @@
         mDbHelper = DbTestUtil.getDbHelperForTest();
         mTopicsDao = new TopicsDao(mDbHelper);
         mEpochManager =
-                new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, mFlags);
+                new EpochManager(
+                        mTopicsDao, mDbHelper, new Random(), mMockClassifier, mFlags, mMockClock);
 
         // Erase all existing data.
         DbTestUtil.deleteTable(TopicsTables.TaxonomyContract.TABLE);
@@ -98,6 +104,7 @@
         DbTestUtil.deleteTable(TopicsTables.ReturnedTopicContract.TABLE);
         DbTestUtil.deleteTable(TopicsTables.UsageHistoryContract.TABLE);
         DbTestUtil.deleteTable(TopicsTables.AppUsageHistoryContract.TABLE);
+        DbTestUtil.deleteTable(TopicsTables.EpochOriginContract.TABLE);
     }
 
     @Test
@@ -188,7 +195,8 @@
                         mDbHelper,
                         new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
                         mMockClassifier,
-                        mFlags);
+                        mFlags,
+                        mMockClock);
 
         Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
         Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -227,7 +235,8 @@
                         mDbHelper,
                         new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
                         mMockClassifier,
-                        mFlags);
+                        mFlags,
+                        mMockClock);
 
         Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
         Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -251,7 +260,8 @@
                         mDbHelper,
                         new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
                         mMockClassifier,
-                        mFlags);
+                        mFlags,
+                        mMockClock);
 
         // Note: we iterate over the appSdksUsageMap. For the test to be deterministic, we use
         // LinkedHashMap so that the order of iteration is defined.
@@ -372,7 +382,13 @@
         when(mockedFlags.getNumberOfEpochsToKeepInHistory()).thenReturn(3);
 
         EpochManager epochManager =
-                new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, mockedFlags);
+                new EpochManager(
+                        mTopicsDao,
+                        mDbHelper,
+                        new Random(),
+                        mMockClassifier,
+                        mockedFlags,
+                        mMockClock);
 
         final long currentEpoch = 6L;
         final int epochLookBackNumberForGarbageCollection = 3;
@@ -438,10 +454,11 @@
                                 mDbHelper,
                                 new MockRandom(new long[] {1, 5, 6, 7, 8, 9}),
                                 mMockClassifier,
-                                mFlags));
+                                mFlags,
+                                mMockClock));
         // Mock EpochManager for getCurrentEpochId()
         final long epochId = 1L;
-        when(epochManager.getCurrentEpochId()).thenReturn(epochId);
+        doReturn(epochId).when(epochManager).getCurrentEpochId();
 
         Topic topic1 = Topic.create(/* topic */ 1, TAXONOMY_VERSION, MODEL_VERSION);
         Topic topic2 = Topic.create(/* topic */ 2, TAXONOMY_VERSION, MODEL_VERSION);
@@ -612,12 +629,17 @@
         EpochManager epochManager =
                 Mockito.spy(
                         new EpochManager(
-                                topicsDao, mDbHelper, new Random(), mMockClassifier, mFlags));
+                                topicsDao,
+                                mDbHelper,
+                                new Random(),
+                                mMockClassifier,
+                                mFlags,
+                                mMockClock));
 
         // To mimic the scenario that there was no usage in last epoch.
         // i.e. current epoch id is 2, with some usages, while epoch id = 1 has no usage.
         final long epochId = 2L;
-        when(epochManager.getCurrentEpochId()).thenReturn(epochId);
+        doReturn(epochId).when(epochManager).getCurrentEpochId();
 
         // Note: we iterate over the appSdksUsageMap. For the test to be deterministic, we use
         // LinkedHashMap so that the order of iteration is defined.
@@ -739,7 +761,8 @@
         Flags flags = mock(Flags.class);
         when(flags.getTopicsNumberOfTopTopics()).thenReturn(3);
         EpochManager epochManager =
-                new EpochManager(mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags);
+                new EpochManager(
+                        mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags, mMockClock);
 
         final String app = "app";
 
@@ -786,4 +809,38 @@
 
         verify(flags, times(6)).getTopicsNumberOfTopTopics();
     }
+
+    @Test
+    public void testGetCurrentEpochId() {
+        Flags flags = mock(Flags.class);
+        when(flags.getTopicsEpochJobPeriodMs()).thenReturn(TOPICS_EPOCH_JOB_PERIOD_MS);
+        // Initialize a local instance of epochManager to use mocked Flags.
+        EpochManager epochManager =
+                new EpochManager(
+                        mTopicsDao, mDbHelper, new Random(), mMockClassifier, flags, mMockClock);
+
+        // Mock clock so that:
+        // 1st call: There is no origin and will set 0 as origin.
+        // 2nd call: The beginning of next epoch
+        // 3rd call: In the middle of the epoch after to test if current time is at somewhere
+        // between two epochs.
+        when(mMockClock.currentTimeMillis())
+                .thenReturn(
+                        0L, TOPICS_EPOCH_JOB_PERIOD_MS, (long) (2.5 * TOPICS_EPOCH_JOB_PERIOD_MS));
+
+        // Origin doesn't exist
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(-1);
+        assertThat(epochManager.getCurrentEpochId()).isEqualTo(0L);
+        // Origin has been persisted
+        assertThat(mTopicsDao.retrieveEpochOrigin()).isEqualTo(0L);
+
+        // 2nd call is on the start of next epoch (epochId = 1)
+        assertThat(epochManager.getCurrentEpochId()).isEqualTo(1L);
+
+        // 3rd call is in the middle of the epoch after (epochId = 2)
+        assertThat(epochManager.getCurrentEpochId()).isEqualTo(2L);
+
+        verify(flags, times(3)).getTopicsEpochJobPeriodMs();
+        verify(mMockClock, times(3)).currentTimeMillis();
+    }
 }