diff --git a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt index 34f4f306..2c080f0f 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/watcher/WebWatcherTest.kt @@ -17,9 +17,9 @@ import net.activitywatch.android.watcher.utils.PAGE_VISIT_TIME_MILLIS import net.activitywatch.android.watcher.utils.createCustomTabsWrapper import org.awaitility.Awaitility.await import org.hamcrest.TypeSafeMatcher +import org.junit.Assume import org.json.JSONArray import org.json.JSONObject -import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -27,13 +27,6 @@ import java.util.concurrent.TimeUnit.MILLISECONDS import kotlin.time.Duration.Companion.milliseconds private const val BUCKET_NAME = "aw-watcher-android-web" -private val SUPPORTED_BROWSERS = setOf( - "com.android.chrome", - "org.mozilla.firefox", - "com.sec.android.app.sbrowser", - "com.opera.browser", - "com.microsoft.emmx", -) @LargeTest @RunWith(AndroidJUnit4::class) @@ -60,12 +53,8 @@ class WebWatcherTest { .also { serviceTestRule.bindService(it) } .also { enableAccessibilityService(serviceName = it.component!!.flattenToString()) } - val availableBrowsers = getAvailableBrowsers() - val browsers = availableBrowsers.filter { it in SUPPORTED_BROWSERS } - assumeTrue( - "No supported browsers installed. VIEW handlers on device: $availableBrowsers", - browsers.isNotEmpty() - ) + val browsers = getAvailableBrowsers() + Assume.assumeTrue("No known browser packages installed on this device", browsers.isNotEmpty()) browsers.forEach { browser -> openUris(uris = testWebPages.map { it.url }, browser = browser) @@ -74,8 +63,7 @@ class WebWatcherTest { val matchers = testWebPages.map { it.toMatcher(browser) } await("expected events for: $browser").atMost(MAX_CONDITION_WAIT_TIME_MILLIS, MILLISECONDS).until { - val rawEvents = ri.getEvents(BUCKET_NAME, 100) - val events = JSONArray(rawEvents).asListOfJsonObjects() + val events = ri.getEventsJSON(BUCKET_NAME, 100).asListOfJsonObjects() .filter { it.getJSONObject("data").getString("browser") == browser } matchers.all { matcher -> events.any { matcher.matches(it) } } @@ -91,7 +79,7 @@ class WebWatcherTest { private fun executeShellCmd(cmd: String) { InstrumentationRegistry.getInstrumentation() .getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES) - .executeShellCommand(cmd) + .executeShellCommand(cmd).close() } private fun getAvailableBrowsers() : List { @@ -99,6 +87,7 @@ class WebWatcherTest { return context.packageManager .queryIntentActivities(activityIntent, PackageManager.MATCH_ALL) .map { it.activityInfo.packageName.toString() } + .filter { it in WebWatcher.KNOWN_BROWSER_PACKAGES } } private fun openUris(uris: List, browser: String) { @@ -161,4 +150,4 @@ class WebWatcherEventMatcher( && expectedTitle?.let { it == title } ?: true && browser == expectedBrowser } -} +} \ No newline at end of file diff --git a/mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt b/mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt index d829c6be..96685952 100644 --- a/mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt +++ b/mobile/src/androidTest/java/net/activitywatch/android/watcher/utils/CustomTabsWrapper.kt @@ -18,7 +18,7 @@ import java.util.concurrent.TimeUnit import kotlin.time.Duration import kotlin.time.toJavaDuration -private const val FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK + Intent.FLAG_ACTIVITY_NO_HISTORY +private const val FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NO_HISTORY fun createCustomTabsWrapper(browser: String, context: Context) : CustomTabsWrapper { val navigationEventsQueue = LinkedBlockingQueue() @@ -96,9 +96,11 @@ private class EventBasedNavigationCompletionAwaiter( private val fallback: NavigationCompletionAwaiter, ) : NavigationCompletionAwaiter { - private var useFallback = false - + // Drop any events left over from a previous page (e.g. a late NAVIGATION_FINISHED that + // arrived after we'd already given up and moved on) so they can't be mistaken for this + // page's events. private fun waitForNavigationStarted() : Boolean { + navigationEventsQueue.clear() val event = navigationEventsQueue.poll(NAVIGATION_STARTED_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS) return event == NAVIGATION_STARTED } @@ -107,14 +109,16 @@ private class EventBasedNavigationCompletionAwaiter( pageVisitTime: Duration, maxWaitTime: Duration ) { - if (!useFallback && waitForNavigationStarted()) { + // Re-attempt the event-based path on every page rather than sticking with the + // fallback forever after a single miss - a transient timeout on one page shouldn't + // permanently downgrade every later page in the same browser session. + if (waitForNavigationStarted()) { await() .pollDelay(pageVisitTime.toJavaDuration()) .atMost(maxWaitTime.toJavaDuration()) .until { navigationEventsQueue.peek() == NAVIGATION_FINISHED } - navigationEventsQueue.peek() + navigationEventsQueue.poll() } else { - useFallback = true fallback.waitForNavigationCompleted(pageVisitTime, maxWaitTime) } } diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/AccessibilityNodeTraversal.kt b/mobile/src/main/java/net/activitywatch/android/watcher/AccessibilityNodeTraversal.kt new file mode 100644 index 00000000..ff89d583 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/watcher/AccessibilityNodeTraversal.kt @@ -0,0 +1,32 @@ +package net.activitywatch.android.watcher + +import android.view.accessibility.AccessibilityNodeInfo + +// Generic depth-first search for the first descendant (including `node` itself) matching +// `predicate`. Every rejected node visited along the way is recycled; the matching node is +// left un-recycled for the caller to use and eventually recycle. +internal fun findNode(node: AccessibilityNodeInfo, predicate: (AccessibilityNodeInfo) -> Boolean): AccessibilityNodeInfo? { + if (predicate(node)) return node + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + val found = findNode(child, predicate) + if (found != null) { + if (found !== child) child.recycle() + return found + } + child.recycle() + } + return null +} + +// Generic depth-first visit of `node` and every descendant. `node` itself is left for the +// caller to recycle (they own that reference); every descendant is recycled once its own +// subtree has been fully visited. +internal fun forEachNode(node: AccessibilityNodeInfo, depth: Int = 0, visit: (AccessibilityNodeInfo, Int) -> Unit) { + visit(node, depth) + for (i in 0 until node.childCount) { + val child = node.getChild(i) ?: continue + forEachNode(child, depth + 1, visit) + child.recycle() + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt new file mode 100644 index 00000000..d69d228b --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/watcher/BrowserSessionTracker.kt @@ -0,0 +1,59 @@ +package net.activitywatch.android.watcher + +import org.threeten.bp.Duration +import org.threeten.bp.Instant + +internal data class CompletedBrowserSession( + val url: String, + val browser: String, + val title: String, + val start: Instant, + val duration: Duration +) + +// Pure session/state-machine logic extracted out of WebWatcher so it can be unit-tested +// (mobile/src/test) without an AccessibilityService or device/emulator. +internal class BrowserSessionTracker( + private val now: () -> Instant = { Instant.ofEpochMilli(System.currentTimeMillis()) } +) { + private var lastUrlTimestamp: Instant? = null + private var lastUrl: String? = null + private var lastBrowser: String? = null + private var lastWindowTitle: String? = null + + // Returns the just-completed session (previous url/browser/title) when the url or + // browser changes, so the caller can log it. We wait for the url to change before + // logging so we have a chance to receive the page title, which often only arrives + // after the page loads and/or the user interacts with it. + fun handleUrl(newUrl: String?, newBrowser: String?): CompletedBrowserSession? { + if (newUrl == lastUrl && newBrowser == lastBrowser) return null + + val completed = lastUrl?.let { url -> + lastBrowser?.let { browser -> + val start = lastUrlTimestamp!! + CompletedBrowserSession( + url = url, + browser = browser, + title = lastWindowTitle ?: "", + start = start, + // Clock can step backward (NTP sync, manual change) between `start` and now; + // don't report a negative duration in that case. + duration = Duration.between(start, now()).coerceAtLeast(Duration.ZERO) + ) + } + } + + lastUrlTimestamp = now() + lastUrl = newUrl + lastBrowser = newBrowser + lastWindowTitle = null + return completed + } + + // Returns true when the title actually changed (so the caller can log it). + fun handleWindowTitle(newWindowTitle: String): Boolean { + if (newWindowTitle == lastWindowTitle) return false + lastWindowTitle = newWindowTitle + return true + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/UrlExtraction.kt b/mobile/src/main/java/net/activitywatch/android/watcher/UrlExtraction.kt new file mode 100644 index 00000000..2d09e9ad --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/watcher/UrlExtraction.kt @@ -0,0 +1,26 @@ +package net.activitywatch.android.watcher + +// Firefox (Compose toolbar): URL is in content-desc of ADDRESSBAR_URL_BOX as +// " {url}. Search or enter address". Greedy so we split at the LAST ". " (URLs can +// themselves contain ". "), and we don't assume the hint text starts with an ASCII +// capital letter since it's localized and may start with a non-Latin character. +private val FIREFOX_SUFFIX_PATTERN = Regex("""^\s*(.+)\.\s+\S""") + +// Pure parsing logic, unit-testable (mobile/src/test) without any Android/Accessibility +// framework dependency. +internal fun parseFirefoxAddressBarContentDescription(contentDescription: String?): String? = + contentDescription + ?.let { FIREFOX_SUFFIX_PATTERN.find(it)?.groupValues?.get(1) } + ?.takeIf { it.isNotBlank() && !it.equals("Search or enter address", ignoreCase = true) } + +// Strips the URI scheme so the same page is represented identically regardless of which +// browser/UI-variant's view happened to include it (e.g. Samsung Internet's regular vs. +// custom-tab toolbar previously disagreed on this, splitting one continuous visit in two). +internal val stripProtocol: (String) -> String = { url -> + url.removePrefix("http://").removePrefix("https://") +} + +// Pure post-processing of text read off an accessibility node: blank text (e.g. a +// momentarily-cleared address bar) is treated as "no url", not as a real value. +internal fun processExtractedText(rawText: String?): String? = + rawText?.takeIf { it.isNotBlank() } diff --git a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt index 206148fe..f17af6a5 100644 --- a/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt +++ b/mobile/src/main/java/net/activitywatch/android/watcher/WebWatcher.kt @@ -1,81 +1,79 @@ package net.activitywatch.android.watcher import android.accessibilityservice.AccessibilityService -import android.os.Handler -import android.os.HandlerThread import android.util.Log import android.view.accessibility.AccessibilityEvent import android.view.accessibility.AccessibilityNodeInfo -import androidx.core.util.Function import net.activitywatch.android.RustInterface import org.json.JSONObject -import org.threeten.bp.Duration -import org.threeten.bp.Instant -private typealias UrlExtractor = (AccessibilityEvent) -> String? - -private fun extractTextByViewId( - event: AccessibilityEvent, - viewId: String, - transformer: Function? = null -) = event.source - ?.findAccessibilityNodeInfosByViewId(viewId) - ?.firstOrNull()?.text?.toString() - ?.let { originalValue -> transformer?.apply(originalValue) ?: originalValue } +private fun extractTextByViewId(event: AccessibilityEvent, viewId: String): String? { + event.source?.let { source -> + val nodes = source.findAccessibilityNodeInfosByViewId(viewId) + try { + return processExtractedText(nodes.firstOrNull()?.text?.toString()) + } finally { + nodes.forEach { it.recycle() } + source.recycle() + } + } + return null +} class WebWatcher : AccessibilityService() { - private val stripProtocol: (String) -> String = { url -> - url.removePrefix("http://").removePrefix("https://") + // The toolbar is a sibling of the content area, so we search from the window root. + // findAccessibilityNodeInfosByViewId requires "package:id/name" format and silently + // rejects bare testTag names, so we traverse manually. + private fun extractFirefoxUrl(event: AccessibilityEvent): String? { + val root = rootInActiveWindow ?: return null + try { + val found = findNode(root) { it.viewIdResourceName == "ADDRESSBAR_URL_BOX" } + val result = parseFirefoxAddressBarContentDescription(found?.contentDescription?.toString()) + if (found !== root) found?.recycle() + return result + } finally { + root.recycle() + } } - private val tag = "WebWatcher" - private val bucketId = "aw-watcher-android-web" - - @Volatile private var ri : RustInterface? = null - private var handler: Handler? = null - private val handlerThread = HandlerThread("WebWatcher").also { it.start() } + private val TAG = "WebWatcher" + private val bucket_id = "aw-watcher-android-web" + private val lastDiagnosticDump = mutableMapOf() - private var lastUrlTimestamp : Instant? = null - private var lastUrl : String? = null - private var lastBrowser: String? = null - private var lastWindowTitle : String? = null + private var ri : RustInterface? = null private var lastWindowId: Int? = null - - private val urlExtractors : Map = mapOf( - "com.android.chrome" to { event -> - extractTextByViewId(event, "com.android.chrome:id/url_bar") - }, - "org.mozilla.firefox" to { event -> - // Firefox has multiple variants depending on version - extractTextByViewId(event, "org.mozilla.firefox:id/url_bar_title") + private val sessionTracker = BrowserSessionTracker() + + // Applies stripProtocol uniformly to whatever extractor matched, so the logged url is + // formatted identically no matter which browser/view-variant produced it. + private fun extractUrl(packageName: String, event: AccessibilityEvent): String? = when (packageName) { + "com.android.chrome" -> extractTextByViewId(event, "com.android.chrome:id/url_bar") + "org.mozilla.firefox" -> + // Compose toolbar (current) + extractFirefoxUrl(event) + // View-based toolbar (older Firefox versions) + ?: extractTextByViewId(event, "org.mozilla.firefox:id/url_bar_title") ?: extractTextByViewId(event, "org.mozilla.firefox:id/mozac_browser_toolbar_url_view") - }, - "com.sec.android.app.sbrowser" to { event -> + "com.sec.android.app.sbrowser" -> extractTextByViewId(event, "com.sec.android.app.sbrowser:id/location_bar_edit_text") - ?: extractTextByViewId(event, "com.sec.android.app.sbrowser:id/custom_tab_toolbar_url_bar_text", transformer = stripProtocol) - }, - "com.opera.browser" to { event -> + ?: extractTextByViewId(event, "com.sec.android.app.sbrowser:id/custom_tab_toolbar_url_bar_text") + "com.opera.browser" -> extractTextByViewId(event, "com.opera.browser:id/url_field") ?: extractTextByViewId(event, "com.opera.browser:id/address_field") - }, - "com.microsoft.emmx" to { event -> - extractTextByViewId(event, "com.microsoft.emmx:id/url_bar") - } - ) + "com.microsoft.emmx" -> extractTextByViewId(event, "com.microsoft.emmx:id/url_bar") + else -> null + }?.let(stripProtocol) override fun onCreate() { super.onCreate() - Log.i(tag, "Creating WebWatcher") - handler = Handler(handlerThread.looper) - handler?.post { - try { - val r = RustInterface(applicationContext) - r.createBucketHelper(bucketId, "web.tab.current") - ri = r - } catch (e: Exception) { - Log.e(tag, "Failed to initialize web bucket", e) - } + Log.i(TAG, "Creating WebWatcher") + try { + ri = RustInterface(applicationContext).also { it.createBucketHelper(bucket_id, "web.tab.current") } + } catch (ex: Throwable) { + // Catch Throwable (not just Exception) because System.loadLibrary() throws + // UnsatisfiedLinkError (an Error subclass) when the native library is missing. + Log.e(TAG, "Failed to initialize RustInterface: ${ex.message}") } } @@ -86,15 +84,16 @@ class WebWatcher : AccessibilityService() { } val packageName = event.packageName?.toString() - val urlExtractor = urlExtractors[packageName] + val isKnownBrowser = packageName != null && packageName in KNOWN_BROWSER_PACKAGES val windowChanged = windowChanged(event.windowId) lastWindowId = event.windowId - if (urlExtractor == null) { + if (!isKnownBrowser) { // for some browsers like Firefox event.packageName can be null (no extractor matched) // but we are still on the same window if (windowChanged) { + Log.i(TAG, "Window changed away from a tracked browser (new package: $packageName); ending session") handleUrl(null, newBrowser = null) } @@ -103,19 +102,25 @@ class WebWatcher : AccessibilityService() { try { event.source?.let { source -> - val newUrl = urlExtractor(event) - - newUrl?.let { handleUrl(it, newBrowser = packageName) }.also { + try { + val browser = packageName!! + val newUrl = extractUrl(browser, event) + + if (newUrl == null) { + maybeDumpTree(browser) + } else { + handleUrl(newUrl, newBrowser = browser) + } findWebView(source)?.let { webView -> handleWindowTitle(webView.text.toString()) - // Recycle the returned node (API 26-32 leaks otherwise), unless it is the - // shared event.source, which the framework owns. if (webView !== source) webView.recycle() } + } finally { + source.recycle() } } } catch(ex : Exception) { - Log.e(tag, ex.message ?: ex.toString()) + Log.e(TAG, ex.message ?: ex.toString()) } } @@ -124,71 +129,79 @@ class WebWatcher : AccessibilityService() { private fun shouldIgnoreEvent(event: AccessibilityEvent) = event.packageName == "com.android.systemui" - private fun findWebView(info: AccessibilityNodeInfo): AccessibilityNodeInfo? { - if (info.className == "android.webkit.WebView" && info.text != null) return info - - for (i in 0 until info.childCount) { - val child = info.getChild(i) ?: continue - val result = findWebView(child) - if (result != null) { - // Recycle the intermediate node when a deeper descendant was returned. - if (result !== child) child.recycle() - return result + // TODO(maintainer): this never finds a match for Firefox, so its page title is never + // captured (logged events show title:""). Confirmed live on-device (2026-07-01, Fenix, + // GeckoView content): dumping the full accessibility tree of rootInActiveWindow while a + // real page was loaded showed `browserLayout` has exactly 3 children - a ComposeView + // (whose only child had zero further descendants), an unexplained childless node with a + // null className, and the toolbar (composable_toolbar, which is where ADDRESSBAR_URL_BOX + // lives - see extractFirefoxUrl above). The page content is not a descendant of this + // window's root at all, so no restructuring of findWebView's search root will find it. + // GeckoView most likely exposes its content as a *separate* accessibility window rather + // than as nodes within this one. Fixing this needs AccessibilityService#getWindows() to + // be enumerated to find the window that hosts GeckoView's content (if any - it's also + // possible the title isn't exposed as an accessibility node at all, e.g. only via a + // window title property), which is a bigger change than this function's shape allows for. + private fun findWebView(info: AccessibilityNodeInfo): AccessibilityNodeInfo? = + findNode(info) { it.className == "android.webkit.WebView" && it.text != null } + + // Dumps the accessibility tree to logcat at debug level, rate-limited to once per minute + // per browser. Helps diagnose URL extraction failures when adding support for new browsers + // or browser versions that have changed their view hierarchy. Enable with: + // adb shell setprop log.tag.WebWatcher DEBUG + private fun maybeDumpTree(packageName: String) { + if (!Log.isLoggable(TAG, Log.DEBUG)) return + val now = System.currentTimeMillis() + if (now - (lastDiagnosticDump[packageName] ?: 0L) < 60_000L) return + lastDiagnosticDump[packageName] = now + val root = rootInActiveWindow ?: return + Log.d(TAG, "URL extraction failed for $packageName — accessibility tree:") + try { + forEachNode(root) { node, depth -> + val id = node.viewIdResourceName ?: "" + val cd = node.contentDescription?.toString()?.take(120) ?: "" + val text = node.text?.toString()?.take(120) ?: "" + if (id.isNotEmpty() || cd.isNotEmpty() || text.isNotEmpty()) { + Log.d(TAG, "${" ".repeat(depth)}class=${node.className} id=$id cd=\"$cd\" text=\"$text\"") + } } - // Recycle nodes whose subtrees contain no WebView. - child.recycle() + } finally { + root.recycle() } - return null } private fun handleUrl(newUrl : String?, newBrowser: String?) { - if (newUrl != lastUrl || newBrowser != lastBrowser) { - newUrl?.let { Log.i(tag, "Url: $it, browser: $newBrowser") } - lastUrl?.let { url -> - lastBrowser?.let { browser -> - // Log last URL and title as a completed browser event. - // We wait for the event to complete (marked by a change in URL) to ensure that - // we had a chance to receive the title of the page, which often only arrives after - // the page loads completely and/or the user interacts with the page. - val windowTitle = lastWindowTitle ?: "" - logBrowserEvent(url, browser, windowTitle, lastUrlTimestamp!!) - } - } - - lastUrlTimestamp = Instant.ofEpochMilli(System.currentTimeMillis()) - lastUrl = newUrl - lastBrowser = newBrowser - lastWindowTitle = null - } + newUrl?.let { Log.i(TAG, "Url: $it, browser: $newBrowser") } + sessionTracker.handleUrl(newUrl, newBrowser)?.let { logBrowserEvent(it) } } private fun handleWindowTitle(newWindowTitle: String) { - if (newWindowTitle != lastWindowTitle) { - lastWindowTitle = newWindowTitle - Log.i(tag, "Title: $lastWindowTitle") + if (sessionTracker.handleWindowTitle(newWindowTitle)) { + Log.i(TAG, "Title: $newWindowTitle") } } - private fun logBrowserEvent(url: String, browser: String, windowTitle: String, lastUrlTimestamp : Instant) { - val now = Instant.ofEpochMilli(System.currentTimeMillis()) - val duration = Duration.between(lastUrlTimestamp, now) - + private fun logBrowserEvent(session: CompletedBrowserSession) { val data = JSONObject() - .put("url", url) - .put("browser", browser) - .put("title", windowTitle) + .put("url", session.url) + .put("browser", session.browser) + .put("title", session.title) .put("audible", false) // TODO .put("incognito", false) // TODO - Log.i(tag, "Registered event: $data") - ri?.heartbeatHelper(bucketId, lastUrlTimestamp, duration.seconds.toDouble(), data, 1.0) + Log.i(TAG, "Registered event: $data") + ri?.heartbeatHelper(bucket_id, session.start, session.duration.seconds.toDouble(), data, 1.0) } override fun onInterrupt() {} - override fun onDestroy() { - super.onDestroy() - handler?.removeCallbacksAndMessages(null) - handlerThread.quitSafely() + companion object { + internal val KNOWN_BROWSER_PACKAGES = setOf( + "com.android.chrome", + "org.mozilla.firefox", + "com.sec.android.app.sbrowser", + "com.opera.browser", + "com.microsoft.emmx" + ) } } diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt new file mode 100644 index 00000000..cf81caef --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/watcher/BrowserSessionTrackerTest.kt @@ -0,0 +1,130 @@ +package net.activitywatch.android.watcher + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.threeten.bp.Instant + +class BrowserSessionTrackerTest { + + // A clock we can advance/rewind by hand so duration math is deterministic and we can + // reproduce a backward clock step (NTP sync, manual change) without waiting on real time. + private class FakeClock(private var instant: Instant) { + fun advanceSeconds(seconds: Long) { instant = instant.plusSeconds(seconds) } + fun rewindSeconds(seconds: Long) { instant = instant.minusSeconds(seconds) } + fun now(): Instant = instant + } + + @Test + fun `first url does not emit a completed session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + val completed = tracker.handleUrl("example.com", "chrome") + + assertNull(completed) + } + + @Test + fun `same url and browser does not emit a completed session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + val completed = tracker.handleUrl("example.com", "chrome") + + assertNull(completed) + } + + @Test + fun `url change emits the previous url as a completed session with correct duration`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + clock.advanceSeconds(30) + val completed = tracker.handleUrl("example.org", "chrome") + + checkNotNull(completed) + assertEquals("example.com", completed.url) + assertEquals("chrome", completed.browser) + assertEquals(Instant.ofEpochSecond(1000), completed.start) + assertEquals(30L, completed.duration.seconds) + } + + @Test + fun `browser change alone (same url) also ends the session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + clock.advanceSeconds(5) + val completed = tracker.handleUrl("example.com", "firefox") + + checkNotNull(completed) + assertEquals("chrome", completed.browser) + } + + @Test + fun `title set before the url changes is attached to the completed session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + tracker.handleWindowTitle("Example Domain") + val completed = tracker.handleUrl("example.org", "chrome") + + checkNotNull(completed) + assertEquals("Example Domain", completed.title) + } + + @Test + fun `missing title defaults to empty string`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + val completed = tracker.handleUrl("example.org", "chrome") + + checkNotNull(completed) + assertEquals("", completed.title) + } + + @Test + fun `title does not carry over into the next session`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + tracker.handleWindowTitle("Example Domain") + tracker.handleUrl("example.org", "chrome") + + // The title for the new page hasn't arrived yet - should not leak the old title. + val completed = tracker.handleUrl("example.net", "chrome") + + checkNotNull(completed) + assertEquals("", completed.title) + } + + @Test + fun `handleWindowTitle reports whether the title actually changed`() { + val tracker = BrowserSessionTracker() + + assertEquals(true, tracker.handleWindowTitle("Example Domain")) + assertEquals(false, tracker.handleWindowTitle("Example Domain")) + assertEquals(true, tracker.handleWindowTitle("Something else")) + } + + @Test + fun `negative duration from a backward clock step is clamped to zero`() { + val clock = FakeClock(Instant.ofEpochSecond(1000)) + val tracker = BrowserSessionTracker(clock::now) + + tracker.handleUrl("example.com", "chrome") + clock.rewindSeconds(60) // simulate NTP sync stepping the clock backward + val completed = tracker.handleUrl("example.org", "chrome") + + checkNotNull(completed) + assertEquals(0L, completed.duration.seconds) + } +} diff --git a/mobile/src/test/java/net/activitywatch/android/watcher/UrlExtractionTest.kt b/mobile/src/test/java/net/activitywatch/android/watcher/UrlExtractionTest.kt new file mode 100644 index 00000000..27dc6f1d --- /dev/null +++ b/mobile/src/test/java/net/activitywatch/android/watcher/UrlExtractionTest.kt @@ -0,0 +1,66 @@ +package net.activitywatch.android.watcher + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UrlExtractionTest { + + @Test + fun `extracts url from typical content description`() { + val cd = " example.com/page. Search or enter address" + assertEquals("example.com/page", parseFirefoxAddressBarContentDescription(cd)) + } + + @Test + fun `returns null for empty address bar placeholder`() { + val cd = ". Search or enter address" + assertNull(parseFirefoxAddressBarContentDescription(cd)) + } + + @Test + fun `returns null for null content description`() { + assertNull(parseFirefoxAddressBarContentDescription(null)) + } + + @Test + fun `returns null when content description has no separator at all`() { + assertNull(parseFirefoxAddressBarContentDescription("Search or enter address")) + } + + @Test + fun `does not truncate a url that itself contains a period-space sequence`() { + // Regression test: a non-greedy regex would previously split at the FIRST ". " + // it found, truncating urls whose own content contains ". ". + val cd = " example.com/search?q=World. Cup. Search or enter address" + assertEquals("example.com/search?q=World. Cup", parseFirefoxAddressBarContentDescription(cd)) + } + + @Test + fun `does not require the hint text to start with an ascii capital letter`() { + // Regression test: locales where the hint text isn't Latin-script (or doesn't + // capitalize) must not disable Firefox tracking entirely. + val cd = " example.com. 搜索或输入网址" + assertEquals("example.com", parseFirefoxAddressBarContentDescription(cd)) + } + + @Test + fun `rejects a bare placeholder that parses as the hint text itself`() { + assertNull(parseFirefoxAddressBarContentDescription("Search or enter address. Search or enter address")) + } + + @Test + fun `processExtractedText filters blank text`() { + assertNull(processExtractedText("")) + assertNull(processExtractedText(" ")) + assertNull(processExtractedText(null)) + assertEquals("example.com", processExtractedText("example.com")) + } + + @Test + fun `stripProtocol removes http and https prefixes only`() { + assertEquals("example.com", stripProtocol("http://example.com")) + assertEquals("example.com", stripProtocol("https://example.com")) + assertEquals("example.com", stripProtocol("example.com")) + } +}