summaryrefslogtreecommitdiff
path: root/app/src/main/java
diff options
context:
space:
mode:
authorJ-Cart <j.t.cartoonist@gmail.com>2025-11-10 11:50:54 +0100
committerJ-Cart <j.t.cartoonist@gmail.com>2025-11-10 11:50:54 +0100
commit55e5df41fc20c03801b2ce990afcdb0c35601b9b (patch)
tree44f9c8b30157e2f0fc389423752f2332eba58de9 /app/src/main/java
parent50a1b9cbc09c06b6fb0c9f7cbbe26316fc599bdf (diff)
downloadSidePhOnelauncher-55e5df41fc20c03801b2ce990afcdb0c35601b9b.tar.gz
SidePhOnelauncher-55e5df41fc20c03801b2ce990afcdb0c35601b9b.tar.bz2
SidePhOnelauncher-55e5df41fc20c03801b2ce990afcdb0c35601b9b.zip
added related files for getting a more accurate device screen time
Diffstat (limited to 'app/src/main/java')
-rw-r--r--app/src/main/java/app/olauncher/helper/usageStats/ComponentForegroundStat.kt28
-rw-r--r--app/src/main/java/app/olauncher/helper/usageStats/EventLogWrapper.kt359
-rw-r--r--app/src/main/java/app/olauncher/helper/usageStats/SimpleUsageStat.kt48
-rw-r--r--app/src/main/java/app/olauncher/helper/usageStats/UnmatchedCloseEventGuardian.kt65
4 files changed, 500 insertions, 0 deletions
diff --git a/app/src/main/java/app/olauncher/helper/usageStats/ComponentForegroundStat.kt b/app/src/main/java/app/olauncher/helper/usageStats/ComponentForegroundStat.kt
new file mode 100644
index 0000000..ca7d033
--- /dev/null
+++ b/app/src/main/java/app/olauncher/helper/usageStats/ComponentForegroundStat.kt
@@ -0,0 +1,28 @@
+package app.olauncher.helper.usageStats
+
+import java.util.Date
+
+/**
+ * Object representing a timespan that an application was in the foreground.
+ *
+ * @property beginTime The start of the timespan, in milliseconds since the epoch.
+ * @property endTime The end of the timespan, in milliseconds since the epoch.
+ * @property packageName The package name of the application.
+ */
+data class ComponentForegroundStat(
+ val beginTime: Long,
+ val endTime: Long,
+ val packageName: String
+) {
+ /**
+ * Overriding toString for a more readable log output, similar to the original Java class.
+ * The default data class toString() would also work, but this one is more explicit
+ * about formatting the timestamps as Instants.
+ */
+ override fun toString(): String {
+ return "ComponentForegroundStat(" +
+ "beginTime=${Date(beginTime)}, " +
+ "endTime=${Date(endTime)}, " +
+ "packageName='$packageName')"
+ }
+}
diff --git a/app/src/main/java/app/olauncher/helper/usageStats/EventLogWrapper.kt b/app/src/main/java/app/olauncher/helper/usageStats/EventLogWrapper.kt
new file mode 100644
index 0000000..18be5bf
--- /dev/null
+++ b/app/src/main/java/app/olauncher/helper/usageStats/EventLogWrapper.kt
@@ -0,0 +1,359 @@
+package app.olauncher.helper.usageStats
+
+import android.app.ActivityManager
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.content.Context
+import android.icu.util.Calendar
+import android.util.Log
+import java.util.concurrent.TimeUnit
+import java.util.function.BiConsumer
+import kotlin.math.max
+import kotlin.math.min
+
+class EventLogWrapper(private val context: Context) {
+ private val usageStatsManager by lazy { context.getSystemService("usagestats") as UsageStatsManager }
+ private val guardian = UnmatchedCloseEventGuardian(usageStatsManager)
+
+
+ /**
+ * Collects event information from system to calculate and aggregate precise
+ * foreground time statistics for the specified period.
+ *
+ * Comments refer to the cases from
+ * [the documentation.](https://codeberg.org/fynngodau/usageDirect/wiki/Event-log-wrapper-scenarios)
+ *
+ * @param start First point in time to include in results
+ * @param end Last point in time to include in results
+ * @return A list of foreground stats for the specified period
+ */
+ fun getForegroundStatsByTimestamps(start: Long, end: Long): List<ComponentForegroundStat> {
+ var queryStart = start // Can be mutated by DEVICE_STARTUP event
+
+ /*
+ * Because sometimes, open events do not have close events when they should, as a hack / workaround,
+ * we query the apps currently in the foreground and match them against the apps that are currently
+ * in the foreground if the query start date is very recent or in the future. Thus, we are using this
+ * to tell apart True from Faulty unmatched open events.
+ *
+ * We query processes in the beginning of this method call in case querying the event log takes a
+ * little longer.
+ */
+ val foregroundProcesses = mutableListOf<String>()
+ if (end >= System.currentTimeMillis() - 1500) {
+ val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
+ activityManager?.runningAppProcesses?.forEach { appProcess ->
+ if (appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND ||
+ appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE
+ ) {
+ if (context.packageName != appProcess.processName) {
+ foregroundProcesses.add(appProcess.processName)
+ }
+ }
+ }
+ }
+
+ // Assumption: events are ordered chronologically
+ val events = usageStatsManager.queryEvents(queryStart, end)
+
+ /* …except that sometimes, the events that are close to each other are swapped in a way that
+ * breaks the assumption that all end times which do not have a matching start time have
+ * started before start. We handle those as Duplicate close event and Duplicate open event.
+ * Therefore, we keep null entries in our moveToForegroundMap instead of removing the entries
+ * to prevent apps that had been opened previously in a period from being counted as "opened
+ * before start" (as they are not a True unmatched close event).
+ */
+ // Map components to the last moveToForeground event. Null value means it was closed.
+ val moveToForegroundMap = mutableMapOf<AppClass, Long?>()
+ val componentForegroundStats = mutableListOf<ComponentForegroundStat>()
+
+ // Iterate over events
+ val event = UsageEvents.Event()
+
+ while (events.hasNextEvent()) {
+ events.getNextEvent(event)
+ if (context.packageName == event.packageName) continue
+
+ val appClass = AppClass(event.packageName, event.className)
+
+ when (event.eventType) {
+ /*
+ * "An event type denoting that an android.app.Activity moved to the foreground."
+ * (old definition: "An event type denoting that a component moved to the foreground.")
+ */
+ UsageEvents.Event.ACTIVITY_RESUMED,
+ /*
+ * public static final int android.app.usage.UsageEvents.Event.CONTINUE_PREVIOUS_DAY = 4;
+ * (annotated as @hide)
+ * "An event type denoting that a component was in the foreground the previous day.
+ * This is effectively treated as a MOVE_TO_FOREGROUND."
+ */
+ 4 -> {
+ // Store open timestamp in map, overwriting earlier timestamps in case of Duplicate open event
+ moveToForegroundMap[appClass] = event.timeStamp
+ }
+
+ /*
+ * "An event type denoting that an android.app.Activity moved to the background."
+ * (old definition: "An event type denoting that a component moved to the background.")
+ */
+ UsageEvents.Event.ACTIVITY_PAUSED,
+ /*
+ * "An activity becomes invisible on the UI, corresponding to Activity.onStop()
+ * of the activity's lifecycle."
+ */
+ UsageEvents.Event.ACTIVITY_STOPPED,
+ /*
+ * public static final int android.app.usage.UsageEvents.Event.END_OF_DAY = 3;
+ * (annotated as @hide)
+ * "An event type denoting that a component was in the foreground when the stats
+ * rolled-over. This is effectively treated as a {@link #MOVE_TO_BACKGROUND}."
+ */
+ 3 -> {
+ val eventBeginTime: Long? = moveToForegroundMap[appClass]?.also {
+ // Open and close events in order. Mark as closed.
+ moveToForegroundMap[appClass] = null
+ } ?: if (
+ // App has not been in this query yet (test for Duplicate close event)
+ moveToForegroundMap.keys.none { it.packageName == event.packageName } &&
+ // Test if this unmatched close event is True by asking the Guardian to scan for it
+ guardian.test(event, queryStart)
+ ) {
+ // Identified as True unmatched close event. Take start as a starting timestamp.
+ queryStart
+ } else {
+ null // Ignore Faulty unmatched close event
+ }
+
+ if (eventBeginTime != null) {
+ // Check if another of the app's components have moved to the foreground in the meantime
+ val endTime = moveToForegroundMap.entries
+ .filter { (key, value) -> key.packageName == event.packageName && value != null }
+ .mapNotNull { it.value }
+ .minOrNull() ?: event.timeStamp
+
+ componentForegroundStats.add(
+ ComponentForegroundStat(eventBeginTime, endTime, event.packageName)
+ )
+ }
+ }
+
+ /*
+ * "An event type denoting that the Android runtime underwent a shutdown process..."
+ */
+ UsageEvents.Event.DEVICE_SHUTDOWN -> {
+ // Per docs: iterate over remaining start events and treat them as closed
+ moveToForegroundMap.forEach { (key, value) ->
+ if (value != null) { // If it's a remaining start event
+ componentForegroundStats.add(
+ ComponentForegroundStat(value, event.timeStamp, key.packageName)
+ )
+ // Set entire app to closed
+ moveToForegroundMap.keys
+ .filter { it.packageName == key.packageName }
+ .forEach { samePackageKey -> moveToForegroundMap[samePackageKey] = null }
+ }
+ }
+ }
+
+ /*
+ * "An event type denoting that the Android runtime started up..."
+ */
+ UsageEvents.Event.DEVICE_STARTUP -> {
+ // Per docs: remove pending open events
+ moveToForegroundMap.clear()
+
+ /* No package could be open longer than a reboot. Thus, we set the `start`
+ * timestamp to the boot event's timestamp in case we later assume that a
+ * package has been open "since the start of the period". It is not logical
+ * that this would happen but we can never know with this API.
+ */
+ queryStart = event.timeStamp
+ }
+ }
+ }
+
+ // Iterate over remaining start events
+ moveToForegroundMap.forEach { (key, value) ->
+ if (value != null) { // If it's a remaining start event
+ // Test if it's a foreground app (True unmatched open event)
+ if (foregroundProcesses.any { it.contains(key.packageName) }) {
+ componentForegroundStats.add(
+ ComponentForegroundStat(
+ value,
+ min(System.currentTimeMillis(), end),
+ key.packageName
+ )
+ )
+ }
+ // If app is not in foreground, drop event (Assume Faulty unmatched open event)
+ }
+ }
+
+ /* If nothing happened during the timespan but there is an app in the foreground,
+ * then this app was used the whole period time and there was No event for it.
+ * Because the foreground applications API call is documented as not to be used
+ * for purposes like this, we first query whether the process name is a valid
+ * package name and if not, we drop it.
+ */
+ if (moveToForegroundMap.isEmpty()) {
+ val packageManager = context.packageManager
+ foregroundProcesses.forEach { foregroundProcess ->
+ if (packageManager.getLaunchIntentForPackage(foregroundProcess) != null) {
+ componentForegroundStats.add(
+ ComponentForegroundStat(
+ queryStart,
+ min(System.currentTimeMillis(), end),
+ foregroundProcess
+ )
+ )
+ Log.d("EventLogWrapper", "Assuming that application $foregroundProcess has been used the whole query time")
+ }
+ }
+ }
+
+ return componentForegroundStats
+ }
+
+ /**
+ * Collects event information from system to calculate and aggregate precise
+ * foreground time statistics for the specified relative day.
+ *
+ * @param offset Day to query back in time relative to today
+ */
+ fun getForegroundStatsByRelativeDay(offset: Int): List<ComponentForegroundStat> {
+ val cal = Calendar.getInstance()
+ cal.add(Calendar.DAY_OF_YEAR, -offset)
+ // Set to start of the day
+ cal.set(Calendar.HOUR_OF_DAY, 0)
+ cal.set(Calendar.MINUTE, 0)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+ val beginTime = cal.timeInMillis
+
+ // Set to start of the next day
+ cal.add(Calendar.DAY_OF_YEAR, 1)
+ val endTime = cal.timeInMillis
+
+ return getForegroundStatsByTimestamps(beginTime, endTime)
+ }
+
+ /**
+ * Collects event information from system to calculate and aggregate precise
+ * foreground time statistics starting at `start` and ending at
+ * the end of the day that contains `start`.
+ *
+ * @param start Starting time of query and point in time in day to query
+ */
+ fun getForegroundStatsByPartialDay(start: Long): List<ComponentForegroundStat> {
+ val cal = Calendar.getInstance()
+ cal.timeInMillis = start
+ // Set to start of the next day
+ cal.add(Calendar.DAY_OF_YEAR, 1)
+ cal.set(Calendar.HOUR_OF_DAY, 0)
+ cal.set(Calendar.MINUTE, 0)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+ val endTime = cal.timeInMillis
+ return getForegroundStatsByTimestamps(start, endTime)
+ }
+
+ /**
+ * Takes a list of foreground stats and aggregates them to usage stats.
+ * Assumes all provided usage stats to be on the same day.
+ *
+ * @param endConsumer Consumer that accepts ending times of component
+ * foreground stats with their package name
+ */
+ @JvmOverloads
+ fun aggregateForegroundStats(
+ foregroundStats: List<ComponentForegroundStat>,
+ endConsumer: BiConsumer<String, Long>? = null
+ ): List<SimpleUsageStat> {
+ if (foregroundStats.isEmpty()) return emptyList()
+
+ // Group by package name and sum the duration for each
+ val applicationTotalTime = foregroundStats
+ .groupBy { it.packageName }
+ .mapValues { (_, stats) -> stats.sumOf { it.endTime - it.beginTime } }
+
+ val firstBeginTime = foregroundStats.first().beginTime
+ val timeZoneOffset = Calendar.getInstance().timeZone.getOffset(firstBeginTime)
+ val day = TimeUnit.MILLISECONDS.toDays(firstBeginTime + timeZoneOffset)
+
+ // Optionally consume end times
+ endConsumer?.let { consumer ->
+ foregroundStats.forEach { consumer.accept(it.packageName, it.endTime) }
+ }
+
+ // Map the aggregated times to SimpleUsageStat objects
+ return applicationTotalTime.map { (packageName, totalTime) ->
+ SimpleUsageStat(day, totalTime, packageName)
+ }
+ }
+
+ /**
+ * Collects <b>all</b> event information from system to calculate and aggregate precise
+ * foreground time statistics for the provided day and presents this information as
+ * [SimpleUsageStat]s.
+ *
+ * @param day Day since epoch
+ */
+ private fun getForegroundStatsByDay(day: Long): List<ComponentForegroundStat> {
+ val cal = Calendar.getInstance()
+ val timeZoneOffset = cal.timeZone.getOffset(System.currentTimeMillis())
+
+ // To convert epoch day to milliseconds, we first get the UTC millis and then adjust for timezone
+ val start = TimeUnit.DAYS.toMillis(day) - timeZoneOffset
+ val end = TimeUnit.DAYS.toMillis(day + 1) - timeZoneOffset
+ return getForegroundStatsByTimestamps(start, end)
+ }
+
+ /**
+ * Collects <b>all</b> event information from system to calculate and aggregate precise
+ * foreground time statistics and presents this information as [SimpleUsageStat]s.
+ * **This method call causes lag** if called with a low since value.
+ *
+ * @param daySince Return data from this day on
+ * @param endConsumer Consumer that accepts ending times of component
+ * foreground stats with their package name
+ */
+ fun getAllSimpleUsageStats(daySince: Long, endConsumer: BiConsumer<String, Long>): List<SimpleUsageStat> {
+ val usageStats = mutableListOf<SimpleUsageStat>()
+ var currentDay = daySince
+
+ val timeZoneOffset = Calendar.getInstance().timeZone.getOffset(System.currentTimeMillis())
+ val today = TimeUnit.MILLISECONDS.toDays(System.currentTimeMillis() + timeZoneOffset)
+
+ // Maximum event log size
+ currentDay = max(today - 10, currentDay)
+
+ while (currentDay <= today) {
+ val foregroundStats = getForegroundStatsByDay(currentDay)
+ usageStats.addAll(aggregateForegroundStats(foregroundStats, endConsumer))
+ currentDay++
+ }
+
+ return usageStats
+ }
+
+ /**
+ * Returns only usage statistics that have not been counted yet for
+ * only the day that contains `timestamp`
+ *
+ * @param endConsumer Consumer that accepts ending times of component
+ * foreground stats with their package name
+ */
+ fun getIncrementalSimpleUsageStats(timestamp: Long, endConsumer: BiConsumer<String, Long>): List<SimpleUsageStat> {
+ val foregroundStats = getForegroundStatsByPartialDay(timestamp)
+ return aggregateForegroundStats(foregroundStats, endConsumer)
+ }
+
+ fun aggregateSimpleUsageStats(usageStats: List<SimpleUsageStat>): Long {
+ return usageStats.sumOf { it.timeUsed }
+ }
+ /**
+ * Stores a class name and its corresponding package.
+ */
+ private data class AppClass(val packageName: String, val className: String?)
+} \ No newline at end of file
diff --git a/app/src/main/java/app/olauncher/helper/usageStats/SimpleUsageStat.kt b/app/src/main/java/app/olauncher/helper/usageStats/SimpleUsageStat.kt
new file mode 100644
index 0000000..93bac4f
--- /dev/null
+++ b/app/src/main/java/app/olauncher/helper/usageStats/SimpleUsageStat.kt
@@ -0,0 +1,48 @@
+package app.olauncher.helper.usageStats
+
+import android.app.usage.UsageStats
+import android.icu.util.Calendar
+import java.util.concurrent.TimeUnit
+
+/**
+ * A data class to hold simplified usage statistics.
+ *
+ * @property day The day since epoch that this object concerns.
+ * @property timeUsed The time that the application has been in the foreground in milliseconds on this day.
+ * @property applicationId The package name of the application that this object concerns.
+ */
+data class SimpleUsageStat(
+ val day: Long,
+ val timeUsed: Long,
+ val applicationId: String
+) {
+ /**
+ * Secondary constructor to create a SimpleUsageStat from the system's [UsageStats].
+ */
+ constructor(systemUsageStat: UsageStats) : this(
+ day = getEpochDay(systemUsageStat.lastTimeUsed),
+ timeUsed = systemUsageStat.totalTimeInForeground,
+ applicationId = systemUsageStat.packageName
+ )
+
+ companion object {
+ /**
+ * Converts a list of system [UsageStats] to a list of [SimpleUsageStat].
+ * This function is kept for direct compatibility with the original Java static method.
+ */
+ @JvmStatic
+ fun asSimpleStats(usageStats: List<UsageStats>): List<SimpleUsageStat> {
+ return usageStats.map { SimpleUsageStat(it) }
+ }
+
+ /**
+ * Calculates the epoch day from a timestamp in milliseconds, compatible with API 24+.
+ * It manually calculates the day by using integer division on the milliseconds.
+ */
+ private fun getEpochDay(lastTimeUsed: Long): Long {
+ val timeZoneOffset = Calendar.getInstance().timeZone.getOffset(lastTimeUsed)
+ return TimeUnit.MILLISECONDS.toDays(lastTimeUsed + timeZoneOffset)
+ }
+ }
+}
+
diff --git a/app/src/main/java/app/olauncher/helper/usageStats/UnmatchedCloseEventGuardian.kt b/app/src/main/java/app/olauncher/helper/usageStats/UnmatchedCloseEventGuardian.kt
new file mode 100644
index 0000000..9d550e2
--- /dev/null
+++ b/app/src/main/java/app/olauncher/helper/usageStats/UnmatchedCloseEventGuardian.kt
@@ -0,0 +1,65 @@
+package app.olauncher.helper.usageStats
+
+import android.app.usage.UsageEvents
+import android.app.usage.UsageStatsManager
+import android.util.Log
+
+/**
+ * “…a diminutive Guardian who traveled backward through time…”
+ *
+ * Guards [EventLogWrapper] against Faulty unmatched close events (per
+ * [the documentation](https://codeberg.org/fynngodau/usageDirect/wiki/Event-log-wrapper-scenarios))
+ * by seeking backwards through time and scanning for the open event.
+ */
+class UnmatchedCloseEventGuardian(private val usageStatsManager: UsageStatsManager) {
+
+ companion object {
+ private const val SCAN_INTERVAL = 1000L * 60 * 60 * 24 // 24 hours
+ }
+
+ /**
+ * @param event Event to validate
+ * @param queryStart Timestamp at which original query o
+ * @return True if the event is valid, false otherwise
+ */
+ fun test(event: UsageEvents.Event, queryStart: Long): Boolean {
+ val events = usageStatsManager.queryEvents(queryStart - SCAN_INTERVAL, queryStart)
+
+ // Reusable event object for iteration
+ val e = UsageEvents.Event()
+
+ // Track whether the package is currently in foreground or background
+ var open = false // Not open until opened
+
+ while (events.hasNextEvent()) {
+ events.getNextEvent(e)
+
+ if (e.eventType == UsageEvents.Event.DEVICE_STARTUP) {
+ // Consider all apps closed after startup according to docs
+ open = false
+ }
+
+ // Only consider events concerning our package otherwise
+ if (event.packageName == e.packageName) {
+ when (e.eventType) {
+ // see EventLogWrapper
+ UsageEvents.Event.ACTIVITY_RESUMED, 4 -> {
+ open = true
+ }
+ UsageEvents.Event.ACTIVITY_PAUSED, 3 -> {
+ if (e.timeStamp != event.timeStamp) {
+ // Don't flip to 'false' if we're looking at the original event itself
+ open = false
+ }
+ }
+ }
+ }
+ }
+
+ val result = if (open) "True" else "Faulty"
+ Log.d("Guardian", "Scanned for package ${event.packageName} and determined event to be $result")
+
+ // Event is valid if it was previously opened (within SCAN_INTERVAL)
+ return open
+ }
+}