Skip to content

Commit 9b94818

Browse files
feat: implemented generic web watcher with support of multiple web browsers
1 parent 51ed857 commit 9b94818

File tree

10 files changed

+498
-162
lines changed

10 files changed

+498
-162
lines changed

mobile/build.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ dependencies {
9090
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
9191
androidTestUtil "androidx.test.services:test-services:$servicesVersion"
9292
androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion"
93+
androidTestImplementation "androidx.browser:browser:1.8.0"
94+
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
95+
androidTestImplementation('org.awaitility:awaitility:4.3.0') {
96+
exclude group: 'org.hamcrest', module: 'hamcrest'
97+
}
9398
}
9499

95100
// Can be used to build with: ./gradlew cargoBuild

mobile/src/androidTest/java/net/activitywatch/android/ScreenshotTest.kt

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,8 @@ import android.content.Intent
44
import android.util.Log
55
import androidx.test.core.app.ActivityScenario
66
import androidx.test.core.app.ApplicationProvider
7-
import androidx.test.core.app.takeScreenshot
87
import androidx.test.core.graphics.writeToTestStorage
9-
import androidx.test.espresso.matcher.ViewMatchers.*
8+
import androidx.test.platform.app.InstrumentationRegistry
109
import androidx.test.rule.GrantPermissionRule
1110
import org.junit.Rule
1211
import org.junit.Test
@@ -50,7 +49,7 @@ class ScreenshotTest {
5049
Thread.sleep(5000)
5150
Log.i(TAG, "Taking screenshot")
5251

53-
val bitmap = takeScreenshot()
52+
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
5453
// Only supported on API levels >=28
5554
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
5655
bitmap.writeToTestStorage("${javaClass.simpleName}_${nameRule.methodName}")
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package net.activitywatch.android.watcher
2+
3+
import android.app.UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.content.pm.PackageManager
7+
import android.net.Uri
8+
import androidx.test.core.app.ApplicationProvider
9+
import androidx.test.ext.junit.runners.AndroidJUnit4
10+
import androidx.test.filters.LargeTest
11+
import androidx.test.platform.app.InstrumentationRegistry
12+
import androidx.test.rule.ServiceTestRule
13+
import net.activitywatch.android.RustInterface
14+
import net.activitywatch.android.watcher.utils.MAX_CONDITION_WAIT_TIME_MILLIS
15+
import net.activitywatch.android.watcher.utils.PAGE_MAX_WAIT_TIME_MILLIS
16+
import net.activitywatch.android.watcher.utils.PAGE_VISIT_TIME_MILLIS
17+
import net.activitywatch.android.watcher.utils.createCustomTabsWrapper
18+
import org.awaitility.Awaitility.await
19+
import org.hamcrest.CoreMatchers.not
20+
import org.hamcrest.MatcherAssert.assertThat
21+
import org.hamcrest.TypeSafeMatcher
22+
import org.json.JSONArray
23+
import org.json.JSONObject
24+
import org.junit.Rule
25+
import org.junit.Test
26+
import org.junit.runner.RunWith
27+
import java.util.concurrent.TimeUnit.MILLISECONDS
28+
import kotlin.time.Duration.Companion.milliseconds
29+
30+
private const val BUCKET_NAME = "aw-watcher-android-web"
31+
32+
@LargeTest
33+
@RunWith(AndroidJUnit4::class)
34+
class WebWatcherTest {
35+
36+
@get:Rule
37+
val serviceTestRule: ServiceTestRule = ServiceTestRule()
38+
39+
private val context = InstrumentationRegistry.getInstrumentation().targetContext
40+
private val applicationContext = ApplicationProvider.getApplicationContext<Context>()
41+
42+
private val testWebPages = listOf(
43+
WebPage("https://example.com", "Example Domain"),
44+
WebPage("https://example.org", "Example Domain"),
45+
WebPage("https://example.net", "Example Domain"),
46+
WebPage("https://w3.org", "W3C"),
47+
)
48+
49+
@Test
50+
fun registerWebActivities() {
51+
val ri = RustInterface(context)
52+
53+
Intent(applicationContext, WebWatcher::class.java)
54+
.also { serviceTestRule.bindService(it) }
55+
.also { enableAccessibilityService(serviceName = it.component!!.flattenToString()) }
56+
57+
val browsers = getAvailableBrowsers()
58+
.also { assertThat(it, not(emptyList())) }
59+
60+
browsers.forEach { browser ->
61+
openUris(uris = testWebPages.map { it.url }, browser = browser)
62+
openHome() // to commit last event
63+
64+
val matchers = testWebPages.map { it.toMatcher(browser) }
65+
66+
await("expected events for: $browser").atMost(MAX_CONDITION_WAIT_TIME_MILLIS, MILLISECONDS).until {
67+
val rawEvents = ri.getEvents(BUCKET_NAME, 100)
68+
val events = JSONArray(rawEvents).asListOfJsonObjects()
69+
.filter { it.getJSONObject("data").getString("browser") == browser }
70+
71+
matchers.all { matcher -> events.any { matcher.matches(it) } }
72+
}
73+
}
74+
}
75+
76+
private fun enableAccessibilityService(serviceName: String) {
77+
executeShellCmd("settings put secure enabled_accessibility_services $serviceName")
78+
executeShellCmd("settings put secure accessibility_enabled 1")
79+
}
80+
81+
private fun executeShellCmd(cmd: String) {
82+
InstrumentationRegistry.getInstrumentation()
83+
.getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
84+
.executeShellCommand(cmd)
85+
}
86+
87+
private fun getAvailableBrowsers() : List<String> {
88+
val activityIntent = Intent(Intent.ACTION_VIEW, Uri.parse("https://"))
89+
return context.packageManager
90+
.queryIntentActivities(activityIntent, PackageManager.MATCH_ALL)
91+
.map { it.activityInfo.packageName.toString() }
92+
}
93+
94+
private fun openUris(uris: List<String>, browser: String) {
95+
val customTabs = createCustomTabsWrapper(browser, context)
96+
uris.forEach { uri -> customTabs.openAndWait(
97+
uri,
98+
pageVisitTime = PAGE_VISIT_TIME_MILLIS.milliseconds,
99+
maxWaitTime = PAGE_MAX_WAIT_TIME_MILLIS.milliseconds
100+
)}
101+
}
102+
103+
private fun openHome() {
104+
val intent = Intent(Intent.ACTION_MAIN).apply {
105+
addCategory(Intent.CATEGORY_HOME)
106+
flags = Intent.FLAG_ACTIVITY_NEW_TASK
107+
}
108+
109+
context.startActivity(intent)
110+
}
111+
}
112+
113+
private fun JSONArray.asListOfJsonObjects() = this.let {
114+
jsonArray -> (0 until jsonArray.length()).map { jsonArray.get(it) as JSONObject }
115+
}
116+
117+
data class WebPage(val url: String, val title: String) {
118+
fun toMatcher(expectedBrowser: String): WebWatcherEventMatcher = WebWatcherEventMatcher(
119+
expectedUrl = url.removePrefix("https://"),
120+
expectedTitle = title.takeIf { shouldMatchTitle(expectedBrowser) },
121+
expectedBrowser = expectedBrowser,
122+
)
123+
124+
// Samsung Internet does not match title at all as no android.webkit.WebView node is present
125+
private fun shouldMatchTitle(browser: String) = browser != "com.sec.android.app.sbrowser"
126+
}
127+
128+
class WebWatcherEventMatcher(
129+
private val expectedUrl: String,
130+
private val expectedTitle: String?,
131+
private val expectedBrowser: String
132+
) : TypeSafeMatcher<JSONObject>() {
133+
134+
override fun describeTo(description: org.hamcrest.Description?) {
135+
description?.appendText("event with url=$expectedUrl registered by: $expectedBrowser")
136+
}
137+
138+
override fun matchesSafely(obj: JSONObject): Boolean {
139+
val timestamp = obj.optString("timestamp", "")
140+
141+
val duration = obj.optLong("duration", -1)
142+
val data = obj.optJSONObject("data")
143+
144+
val url = data?.optString("url")
145+
val title = data?.optString("title")
146+
val browser = data?.optString("browser")
147+
148+
return timestamp.isNotBlank()
149+
&& duration >= 0
150+
&& url?.startsWith(expectedUrl) ?: false
151+
&& expectedTitle?.let { it == title } ?: true
152+
&& browser == expectedBrowser
153+
}
154+
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package net.activitywatch.android.watcher.utils
2+
3+
import android.content.ComponentName
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.net.Uri
7+
import android.os.Bundle
8+
import androidx.browser.customtabs.CustomTabsCallback
9+
import androidx.browser.customtabs.CustomTabsCallback.NAVIGATION_FINISHED
10+
import androidx.browser.customtabs.CustomTabsCallback.NAVIGATION_STARTED
11+
import androidx.browser.customtabs.CustomTabsClient
12+
import androidx.browser.customtabs.CustomTabsIntent
13+
import androidx.browser.customtabs.CustomTabsServiceConnection
14+
import org.awaitility.Awaitility.await
15+
import java.util.concurrent.CompletableFuture
16+
import java.util.concurrent.LinkedBlockingQueue
17+
import java.util.concurrent.TimeUnit
18+
import kotlin.time.Duration
19+
import kotlin.time.toJavaDuration
20+
21+
private const val FLAGS = Intent.FLAG_ACTIVITY_NEW_TASK + Intent.FLAG_ACTIVITY_NO_HISTORY
22+
23+
fun createCustomTabsWrapper(browser: String, context: Context) : CustomTabsWrapper {
24+
val navigationEventsQueue = LinkedBlockingQueue<Int>()
25+
26+
val customTabsCallback = object : CustomTabsCallback() {
27+
override fun onNavigationEvent(navigationEvent: Int, extras: Bundle?) {
28+
if (navigationEvent == NAVIGATION_STARTED || navigationEvent == NAVIGATION_FINISHED) {
29+
navigationEventsQueue.offer(navigationEvent)
30+
}
31+
}
32+
}
33+
34+
val customTabsIntent =
35+
createCustomTabsIntentWithCallback(context, browser, customTabsCallback)
36+
?: createFallbackCustomTabsIntent(browser)
37+
38+
return CustomTabsWrapper(customTabsIntent, context, navigationEventsQueue)
39+
}
40+
41+
private fun createCustomTabsIntentWithCallback(context: Context, browser: String, callback: CustomTabsCallback) : CustomTabsIntent? {
42+
val customTabsIntent: CompletableFuture<CustomTabsIntent> = CompletableFuture()
43+
44+
return CustomTabsClient.bindCustomTabsService(context, browser, object : CustomTabsServiceConnection() {
45+
override fun onCustomTabsServiceConnected(name: ComponentName, client: CustomTabsClient) {
46+
val session = client.newSession(callback)
47+
val warmupResult = client.warmup(0)
48+
warmupResult.toString()
49+
50+
customTabsIntent.complete(
51+
CustomTabsIntent.Builder(session).build().also {
52+
it.intent.setPackage(browser)
53+
it.intent.addFlags(FLAGS)
54+
}
55+
)
56+
}
57+
58+
override fun onServiceDisconnected(name: ComponentName) {}
59+
}).takeIf { it }?.run {
60+
customTabsIntent.get(CUSTOM_TABS_SERVICE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
61+
}
62+
}
63+
64+
private fun createFallbackCustomTabsIntent(browser: String) = CustomTabsIntent.Builder().build()
65+
.also {
66+
it.intent.setPackage(browser)
67+
it.intent.addFlags(FLAGS)
68+
}
69+
70+
class CustomTabsWrapper(
71+
private val customTabsIntent: CustomTabsIntent,
72+
private val context: Context,
73+
navigationEventsQueue: LinkedBlockingQueue<Int>?
74+
) {
75+
76+
private val navigationCompletionAwaiter : NavigationCompletionAwaiter;
77+
78+
init {
79+
val fallback = FallbackNavigationCompletionAwaiter()
80+
navigationCompletionAwaiter = navigationEventsQueue?.let {
81+
EventBasedNavigationCompletionAwaiter(it, fallback)
82+
} ?: fallback
83+
}
84+
85+
fun openAndWait(uri: String, pageVisitTime: Duration, maxWaitTime: Duration) {
86+
customTabsIntent.launchUrl(context, Uri.parse(uri))
87+
navigationCompletionAwaiter.waitForNavigationCompleted(pageVisitTime, maxWaitTime)
88+
}
89+
}
90+
91+
private interface NavigationCompletionAwaiter {
92+
fun waitForNavigationCompleted(pageVisitTime: Duration, maxWaitTime: Duration)
93+
}
94+
95+
private class EventBasedNavigationCompletionAwaiter(
96+
private val navigationEventsQueue: LinkedBlockingQueue<Int>,
97+
private val fallback: NavigationCompletionAwaiter,
98+
) : NavigationCompletionAwaiter {
99+
100+
private var useFallback = false
101+
102+
private fun waitForNavigationStarted() : Boolean {
103+
val event = navigationEventsQueue.poll(NAVIGATION_STARTED_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)
104+
return event == NAVIGATION_STARTED
105+
}
106+
107+
override fun waitForNavigationCompleted(
108+
pageVisitTime: Duration,
109+
maxWaitTime: Duration
110+
) {
111+
if (!useFallback && waitForNavigationStarted()) {
112+
await()
113+
.pollDelay(pageVisitTime.toJavaDuration())
114+
.atMost(maxWaitTime.toJavaDuration())
115+
.until { navigationEventsQueue.peek() == NAVIGATION_FINISHED }
116+
navigationEventsQueue.peek()
117+
} else {
118+
useFallback = true
119+
fallback.waitForNavigationCompleted(pageVisitTime, maxWaitTime)
120+
}
121+
}
122+
}
123+
124+
private class FallbackNavigationCompletionAwaiter : NavigationCompletionAwaiter {
125+
override fun waitForNavigationCompleted(
126+
pageVisitTime: Duration,
127+
maxWaitTime: Duration
128+
) {
129+
await()
130+
.pollDelay(pageVisitTime.toJavaDuration())
131+
.atMost(maxWaitTime.toJavaDuration())
132+
.until { true } // just wait page visit time as no callback is available
133+
}
134+
135+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package net.activitywatch.android.watcher.utils
2+
3+
const val PAGE_VISIT_TIME_MILLIS = 5000L
4+
const val PAGE_MAX_WAIT_TIME_MILLIS = 10000L
5+
const val MAX_CONDITION_WAIT_TIME_MILLIS = 10000L
6+
7+
const val CUSTOM_TABS_SERVICE_TIMEOUT_MILLIS = 30000L
8+
const val NAVIGATION_STARTED_TIMEOUT_MILLIS = 10000L

mobile/src/main/AndroidManifest.xml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3-
xmlns:tools="http://schemas.android.com/tools">
3+
xmlns:tools="http://schemas.android.com/tools"
4+
tools:ignore="MissingLeanbackLauncher">
45

56
<uses-feature
67
android:name="android.software.leanback"
@@ -11,7 +12,7 @@
1112

1213
<uses-permission android:name="android.permission.INTERNET"/>
1314
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
14-
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
15+
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:ignore="QueryAllPackagesPermission" />
1516
<uses-permission
1617
android:name="android.permission.PACKAGE_USAGE_STATS"
1718
tools:ignore="ProtectedPermissions"/>
@@ -58,7 +59,7 @@
5859
</intent-filter>
5960
</receiver>
6061

61-
<service android:name=".watcher.ChromeWatcher"
62+
<service android:name=".watcher.WebWatcher"
6263
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
6364
android:exported="true">
6465
<intent-filter>

0 commit comments

Comments
 (0)