Skip to content
Open
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
1 change: 1 addition & 0 deletions .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.

buildscript {
ext.kotlin_version = '1.7.20'
ext.androidXTestVersion = '1.5.0'
ext.espressoVersion = '3.5.0'
ext.extJUnitVersion = '1.1.4'
ext.kotlin_version = '1.9.0'
ext.androidXTestVersion = '1.7.0'
ext.espressoVersion = '3.7.0'
ext.extJUnitVersion = '1.3.0'
ext.servicesVersion = '1.4.2'
repositories {
google()
Expand All @@ -14,7 +14,7 @@ buildscript {
}
}
dependencies {
classpath 'com.android.tools.build:gradle:8.1.1'
classpath 'com.android.tools.build:gradle:8.11.2'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
classpath 'gradle.plugin.org.mozilla.rust-android-gradle:plugin:0.8.3'

Expand Down
3 changes: 2 additions & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#Fri Sep 26 14:49:59 CEST 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
26 changes: 17 additions & 9 deletions mobile/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,13 @@ android {
}
}
compileOptions {
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
coreLibraryDesugaringEnabled true
}

kotlinOptions {
jvmTarget = "1.8"
jvmTarget = "11"
}
namespace 'net.activitywatch.android'
// Never got this to work...
Expand All @@ -69,19 +70,21 @@ android {
}

dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.5'

implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"

implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
implementation 'androidx.annotation:annotation:1.5.0'
implementation 'androidx.annotation:annotation:1.9.1'

implementation 'com.google.android.material:material:1.7.0'
implementation 'com.jakewharton.threetenabp:threetenabp:1.4.3'
implementation 'com.google.android.material:material:1.13.0'
implementation 'com.jakewharton.threetenabp:threetenabp:1.4.9'

testImplementation "junit:junit:4.13.2"
androidTestImplementation "androidx.test.ext:junit-ktx:$extJUnitVersion"
Expand All @@ -90,6 +93,11 @@ dependencies {
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
androidTestUtil "androidx.test.services:test-services:$servicesVersion"
androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion"
androidTestImplementation "androidx.browser:browser:1.8.0"
androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0'
androidTestImplementation('org.awaitility:awaitility:4.3.0') {
exclude group: 'org.hamcrest', module: 'hamcrest'
}
}

// Can be used to build with: ./gradlew cargoBuild
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import android.content.Intent
import android.util.Log
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.core.app.takeScreenshot
import androidx.test.core.graphics.writeToTestStorage
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.GrantPermissionRule
import org.junit.Rule
import org.junit.Test
Expand Down Expand Up @@ -50,7 +49,7 @@ class ScreenshotTest {
Thread.sleep(5000)
Log.i(TAG, "Taking screenshot")

val bitmap = takeScreenshot()
val bitmap = InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot()
// Only supported on API levels >=28
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) {
bitmap.writeToTestStorage("${javaClass.simpleName}_${nameRule.methodName}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package net.activitywatch.android.watcher

import android.app.UiAutomation.FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ServiceTestRule
import net.activitywatch.android.RustInterface
import net.activitywatch.android.watcher.utils.MAX_CONDITION_WAIT_TIME_MILLIS
import net.activitywatch.android.watcher.utils.PAGE_MAX_WAIT_TIME_MILLIS
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.CoreMatchers.not
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.TypeSafeMatcher
import org.json.JSONArray
import org.json.JSONObject
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"

@LargeTest
@RunWith(AndroidJUnit4::class)
class WebWatcherTest {

@get:Rule
val serviceTestRule: ServiceTestRule = ServiceTestRule()

private val context = InstrumentationRegistry.getInstrumentation().targetContext
private val applicationContext = ApplicationProvider.getApplicationContext<Context>()

private val testWebPages = listOf(
WebPage("https://example.com", "Example Domain"),
WebPage("https://example.org", "Example Domain"),
WebPage("https://example.net", "Example Domain"),
WebPage("https://w3.org", "W3C"),
)

@Test
fun registerWebActivities() {
val ri = RustInterface(context)

Intent(applicationContext, WebWatcher::class.java)
.also { serviceTestRule.bindService(it) }
.also { enableAccessibilityService(serviceName = it.component!!.flattenToString()) }

val browsers = getAvailableBrowsers()
.also { assertThat(it, not(emptyList())) }

browsers.forEach { browser ->
openUris(uris = testWebPages.map { it.url }, browser = browser)
openHome() // to commit last event

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()
.filter { it.getJSONObject("data").getString("browser") == browser }

matchers.all { matcher -> events.any { matcher.matches(it) } }
}
}
}

private fun enableAccessibilityService(serviceName: String) {
executeShellCmd("settings put secure enabled_accessibility_services $serviceName")
executeShellCmd("settings put secure accessibility_enabled 1")
}

private fun executeShellCmd(cmd: String) {
InstrumentationRegistry.getInstrumentation()
.getUiAutomation(FLAG_DONT_SUPPRESS_ACCESSIBILITY_SERVICES)
.executeShellCommand(cmd)
}

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() }
}

private fun openUris(uris: List<String>, browser: String) {
val customTabs = createCustomTabsWrapper(browser, context)
uris.forEach { uri -> customTabs.openAndWait(
uri,
pageVisitTime = PAGE_VISIT_TIME_MILLIS.milliseconds,
maxWaitTime = PAGE_MAX_WAIT_TIME_MILLIS.milliseconds
)}
}

private fun openHome() {
val intent = Intent(Intent.ACTION_MAIN).apply {
addCategory(Intent.CATEGORY_HOME)
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}

context.startActivity(intent)
}
}

private fun JSONArray.asListOfJsonObjects() = this.let {
jsonArray -> (0 until jsonArray.length()).map { jsonArray.get(it) as JSONObject }
}

data class WebPage(val url: String, val title: String) {
fun toMatcher(expectedBrowser: String): WebWatcherEventMatcher = WebWatcherEventMatcher(
expectedUrl = url.removePrefix("https://"),
expectedTitle = title.takeIf { shouldMatchTitle(expectedBrowser) },
expectedBrowser = expectedBrowser,
)

// Samsung Internet does not match title at all as no android.webkit.WebView node is present
private fun shouldMatchTitle(browser: String) = browser != "com.sec.android.app.sbrowser"
}

class WebWatcherEventMatcher(
private val expectedUrl: String,
private val expectedTitle: String?,
private val expectedBrowser: String
) : TypeSafeMatcher<JSONObject>() {

override fun describeTo(description: org.hamcrest.Description?) {
description?.appendText("event with url=$expectedUrl registered by: $expectedBrowser")
}

override fun matchesSafely(obj: JSONObject): Boolean {
val timestamp = obj.optString("timestamp", "")

val duration = obj.optLong("duration", -1)
val data = obj.optJSONObject("data")

val url = data?.optString("url")
val title = data?.optString("title")
val browser = data?.optString("browser")

return timestamp.isNotBlank()
&& duration >= 0
&& url?.startsWith(expectedUrl) ?: false
&& expectedTitle?.let { it == title } ?: true
&& browser == expectedBrowser
}
}
Loading