Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions parent/src/main/java/org/openscreentime/parent/ui/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,20 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
if (intent.getBooleanExtra(EXTRA_OPEN_DIGEST, false)) openDigest.value = true
consumeOpenDigest(intent)
}

/**
* Reads the "open the calm list" request once, then removes it from the intent. Left in place,
* the extra survived every activity recreation - a theme change, the app lock, Android
* reclaiming the activity - and each one pushed another copy of the screen, so leaving it took
* as many presses of Back as there had been recreations.
*/
private fun consumeOpenDigest(intent: Intent) {
if (intent.getBooleanExtra(EXTRA_OPEN_DIGEST, false)) {
intent.removeExtra(EXTRA_OPEN_DIGEST)
openDigest.value = true
}
}

private fun requestWebsiteFilter() {
Expand All @@ -94,7 +107,8 @@ class MainActivity : FragmentActivity() {
val repository = (application as ParentApp).repository
val appearancePrefs = AppearancePrefs(this)
val selfProfileStore = SelfProfileStore(this)
if (intent.getBooleanExtra(EXTRA_OPEN_DIGEST, false)) openDigest.value = true
// A recreated activity gets its original intent back; only a fresh launch should act on it.
if (savedInstanceState == null) consumeOpenDigest(intent)

// If the parent already allowed the website filter, keep it running while they track themselves.
if (selfProfileStore.isTracking && VpnService.prepare(this) == null) startWebsiteFilter()
Expand Down Expand Up @@ -190,7 +204,7 @@ class MainActivity : FragmentActivity() {
LaunchedEffect(openDigest.value) {
if (openDigest.value) {
openDigest.value = false
navController.navigate("digest")
navController.navigate("digest") { launchSingleTop = true }
}
}
NavHost(navController = navController, startDestination = "dashboard") {
Expand All @@ -207,7 +221,7 @@ class MainActivity : FragmentActivity() {
},
onOpenNearbyLink = { navController.navigate("nearby") },
onOpenHelp = { navController.navigate("help") },
onOpenDigest = { navController.navigate("digest") },
onOpenDigest = { navController.navigate("digest") { launchSingleTop = true } },
onRequestNotificationListener = {
startActivity(Intent(Settings.ACTION_NOTIFICATION_LISTENER_SETTINGS))
},
Expand Down
42 changes: 42 additions & 0 deletions shared/src/main/java/org/openscreentime/shared/model/WindowKind.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.openscreentime.shared.model

/**
* What a window coming to the front actually is, for the purpose of deciding whose time it is.
*
* Android reports every window that takes focus - not only apps, but the keyboard, the notification
* shade, the volume slider, the home screen. Treating all of them as apps is what made one person's
* browser look undercounted: typing a search opened the keyboard, the keyboard became "the app in
* front", and the browser stopped being credited until the keyboard closed again.
*/
enum class WindowKind {
/** A real app: its time is its own. */
APP,

/**
* Something drawn over the app a person is using - the keyboard, the notification shade, the
* lock screen, a system dialog. The app underneath is still the one being used, so it keeps the
* time.
*/
OVERLAY,

/** The home screen. Nobody is in an app, so no app is credited; it still counts as screen time. */
HOME,

/** OpenScreenTime itself, which never counts against anyone. */
SELF
}

fun classifyWindow(
packageName: String,
ownPackage: String,
homePackages: Set<String>,
overlayPackages: Set<String>
): WindowKind = when {
packageName == ownPackage -> WindowKind.SELF
packageName in overlayPackages -> WindowKind.OVERLAY
packageName in homePackages -> WindowKind.HOME
else -> WindowKind.APP
}

/** Always drawn over whatever is underneath: the status bar, shade, lock screen and system dialogs. */
const val SYSTEM_UI_PACKAGE = "com.android.systemui"
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@ package org.openscreentime.shared.util
import android.accessibilityservice.AccessibilityService
import android.app.Notification
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.PowerManager
import android.view.inputmethod.InputMethodManager
import android.os.Handler
import android.os.Looper
import android.view.accessibility.AccessibilityEvent
Expand All @@ -19,6 +24,9 @@ import org.openscreentime.shared.model.hasUnlockWindowExpired
import org.openscreentime.shared.model.isFirstAppAfterUnlock
import org.openscreentime.shared.model.nowMinutesOfDay
import org.openscreentime.shared.model.relockDue
import org.openscreentime.shared.model.SYSTEM_UI_PACKAGE
import org.openscreentime.shared.model.WindowKind
import org.openscreentime.shared.model.classifyWindow

/**
* The foreground guard both apps run through Android's accessibility events: it attributes time to the app in
Expand Down Expand Up @@ -66,44 +74,106 @@ abstract class BaseAppLimitAccessibilityService : AccessibilityService() {
private var currentPackage: String? = null
private var currentPackageStartMs: Long = 0L

private val power by lazy { getSystemService(Context.POWER_SERVICE) as PowerManager }

private val tick = object : Runnable {
override fun run() {
flushCurrent(restart = true)
// The tick keeps running while the phone is locked. Crediting the app that happened to
// be in front when the screen went off, every thirty seconds all night, is what turned a
// browser into a fourteen-hour app. If the screen is off, nobody is using anything.
if (!power.isInteractive) stopCrediting() else flushCurrent(restart = true)
checkRelock()
checkLimits(currentPackage)
updateStatusNotification()
handler.postDelayed(this, TICK_INTERVAL_MS)
}
}

/**
* Screen off and unlock, heard here as well as by the screen-monitor service. That service is
* the one that counts unlocks; this one only needs to know when to stop and start crediting
* apps, and hearing it directly means one of the two being killed no longer leaves a session
* running all night. Both calls below are safe to receive twice.
*/
private val screenReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_SCREEN_OFF -> {
stopCrediting()
usageStore.endSessionAndFlush()
}
Intent.ACTION_USER_PRESENT -> resumeCrediting()
}
}
}

override fun onServiceConnected() {
usageStore = openUsageStore()
onConnected()
registerReceiver(screenReceiver, IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_OFF)
addAction(Intent.ACTION_USER_PRESENT)
})
handler.postDelayed(tick, TICK_INTERVAL_MS)
}

override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(tick)
runCatching { unregisterReceiver(screenReceiver) }
}

/** Folds in the app in front up to now, then credits nobody until something new comes forward. */
private fun stopCrediting() {
flushCurrent(restart = false)
currentPackage = null
}

/**
* Just unlocked. Unlocking straight back into the app that was open does not always produce a
* window event, so ask which window is in front rather than waiting for one that may not come.
*/
private fun resumeCrediting() {
val pkg = runCatching { rootInActiveWindow?.packageName?.toString() }.getOrNull() ?: return
if (kindOf(pkg) != WindowKind.APP) return
currentPackage = pkg
currentPackageStartMs = System.currentTimeMillis()
cacheAppLabel(pkg)
}

override fun onAccessibilityEvent(event: AccessibilityEvent?) {
if (event?.eventType != AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) return
val pkg = event.packageName?.toString() ?: return
val kind = kindOf(pkg)

// The keyboard, the notification shade, a volume slider: drawn over the app being used, and
// not a change of app. Crediting them took time off whatever was underneath - typing a search
// made the browser stop counting until the keyboard went away.
if (kind == WindowKind.OVERLAY) return

settings.foregroundPackage = pkg
// "Dumb phone" (see #42): an app that isn't allowed goes straight back to the home screen.
if (focusEnforcer.enforce(pkg)) return
recordFirstAppIfPending(pkg)
if (pkg == packageName || pkg == currentPackage) return
if (kind == WindowKind.SELF || pkg == currentPackage) return

flushCurrent(restart = false)
if (kind == WindowKind.HOME) {
// On the home screen nobody is in an app. Still screen time, not anyone's app time.
currentPackage = null
updateStatusNotification()
return
}
currentPackage = pkg
currentPackageStartMs = System.currentTimeMillis()
cacheAppLabel(pkg)
checkLimits(pkg)
updateStatusNotification()
}

private fun kindOf(pkg: String): WindowKind =
classifyWindow(pkg, packageName, homePackages, overlayPackages)

/**
* See #35 - if a parent has unlock tracking on, the unlock receiver left a pending unlock
* behind; the first real app to come forward within the window is the one recorded. This runs
Expand All @@ -124,13 +194,29 @@ abstract class BaseAppLimitAccessibilityService : AccessibilityService() {
}

/** The home launcher(s) and system UI: arriving there right after an unlock isn't opening an app. */
private val ignoredFirstAppPackages: Set<String> by lazy {
private val ignoredFirstAppPackages: Set<String> by lazy { homePackages + SYSTEM_UI_PACKAGE }

/** Every installed home screen, not only the current default: people switch launchers. */
private val homePackages: Set<String> by lazy {
val home = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME)
packageManager.queryIntentActivities(home, PackageManager.MATCH_DEFAULT_ONLY)
.map { it.activityInfo.packageName }
.toSet() + "com.android.systemui"
.toSet()
}

/**
* What draws over apps rather than replacing them. Keyboards are read from the system each
* time rather than once, since a person can install or switch keyboards while this is running.
*/
private val overlayPackages: Set<String>
get() {
val keyboards = runCatching {
(getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.enabledInputMethodList.map { it.packageName }
}.getOrDefault(emptyList())
return keyboards.toSet() + SYSTEM_UI_PACKAGE
}

/** Adds elapsed time for the current package. If [restart], keeps tracking it from now. */
private fun flushCurrent(restart: Boolean) {
val pkg = currentPackage ?: return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,12 @@ import android.content.Context
* "self_usage" on the parent's own, so what is saved stays exactly where it was. The rules are all in [DayLedger].
* The friction-pause record is kept by both but only used where a pause screen exists.
*/
open class DailyUsageStore(context: Context, prefsName: String) :
DayLedger(SharedPrefsStore(context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)))
open class DailyUsageStore(context: Context, prefsName: String) : DayLedger(
store = SharedPrefsStore(context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)),
isScreenOn = screenOnCheck(context)
)

private fun screenOnCheck(context: Context): () -> Boolean {
val power = context.applicationContext.getSystemService(Context.POWER_SERVICE) as? android.os.PowerManager
return { power?.isInteractive ?: true }
}
24 changes: 22 additions & 2 deletions shared/src/main/java/org/openscreentime/shared/util/DayLedger.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,13 @@ import org.openscreentime.shared.model.todayDateString
open class DayLedger(
private val store: KeyValueStore,
private val nowMs: () -> Long = System::currentTimeMillis,
private val todayString: () -> String = ::todayDateString
private val todayString: () -> String = ::todayDateString,
/**
* Whether the screen is on right now. A session can only be "in progress" while it is: if the
* screen-off broadcast was missed - the service that hears it was killed, which some phones do
* routinely - the session would otherwise stay open and count every hour the phone sat locked.
*/
private val isScreenOn: () -> Boolean = { true }
) : UsageReader {
private val json = Json { ignoreUnknownKeys = true }

Expand All @@ -40,10 +46,22 @@ open class DayLedger(
putBoolean("warnedDaily", false)
putStringSet("warnedApps", emptySet())
putStringSet("pausedApps", emptySet())
// A session still open at midnight belongs to both days. Leaving its start where it
// was put the whole of it on the new day - so a phone left unlocked overnight, or one
// whose screen-off went unheard, woke up with hours already on today's clock.
if (store.getLong("sessionStartMs", -1) >= 0) putLong("sessionStartMs", startOfTodayMs())
}
}
}

private fun startOfTodayMs(): Long = java.util.Calendar.getInstance().apply {
timeInMillis = nowMs()
set(java.util.Calendar.HOUR_OF_DAY, 0)
set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0)
set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis

fun addScreenTime(ms: Long) {
if (ms <= 0) return
rolloverIfNeeded()
Expand Down Expand Up @@ -96,7 +114,9 @@ open class DayLedger(
rolloverIfNeeded()
val ended = store.getLong("totalScreenTimeMs", 0)
val start = sessionStartMs
val inProgress = if (start == null) 0L else nowMs() - start
// Only while the screen is actually on: an open session with the screen off is one whose
// end was never heard, and counting it is how a day reached fourteen hours.
val inProgress = if (start == null || !isScreenOn()) 0L else (nowMs() - start).coerceAtLeast(0)
return countedScreenTimeMs(ended + inProgress, store.getLong("excludedMs", 0))
}

Expand Down
Loading
Loading