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+ }
0 commit comments