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
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,16 @@ 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
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)
Expand All @@ -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)
Expand All @@ -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) } }
Expand All @@ -91,14 +79,15 @@ class WebWatcherTest {
private fun executeShellCmd(cmd: String) {
InstrumentationRegistry.getInstrumentation()
.getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
.executeShellCommand(cmd)
.executeShellCommand(cmd).close()
}

private fun getAvailableBrowsers() : List<String> {
val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://"))
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<String>, browser: String) {
Expand Down Expand Up @@ -161,4 +150,4 @@ class WebWatcherEventMatcher(
&& expectedTitle?.let { it == title } ?: true
&& browser == expectedBrowser
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>()
Expand Down Expand Up @@ -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
}
Expand All @@ -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)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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() }
Loading
Loading