diff --git a/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt new file mode 100644 index 00000000..467e7888 --- /dev/null +++ b/mobile/src/androidTest/java/net/activitywatch/android/NativeWindowInsetsTest.kt @@ -0,0 +1,162 @@ +package net.activitywatch.android + +import android.Manifest +import android.app.Activity +import android.os.Build +import android.os.ParcelFileDescriptor +import android.view.View +import android.view.ViewGroup +import androidx.core.view.GravityCompat +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import org.junit.Assert.* +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +/** Run on API 36 with a display cutout, and an older API, in both navigation modes. */ +@RunWith(AndroidJUnit4::class) +class NativeWindowInsetsTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val device = UiDevice.getInstance(instrumentation) + private val packageName = instrumentation.targetContext.packageName + private var originalUsageAccessMode: String? = null + private var notificationPermissionWasGranted = false + + private fun shell(command: String): String = + ParcelFileDescriptor.AutoCloseInputStream( + instrumentation.uiAutomation.executeShellCommand(command) + ).bufferedReader().use { it.readText() } + + @Before + fun grantRequiredAccess() { + // PACKAGE_USAGE_STATS is controlled by AppOps, not a runtime permission. + // GrantPermissionRule cannot enable it on a fresh CI emulator. + originalUsageAccessMode = Regex("GET_USAGE_STATS: (\\w+)") + .find(shell("appops get $packageName GET_USAGE_STATS")) + ?.groupValues?.get(1) ?: "default" + shell("appops set $packageName GET_USAGE_STATS allow") + if (Build.VERSION.SDK_INT >= 33) { + notificationPermissionWasGranted = instrumentation.targetContext + .checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) == + android.content.pm.PackageManager.PERMISSION_GRANTED + if (!notificationPermissionWasGranted) { + instrumentation.uiAutomation.grantRuntimePermission( + packageName, + Manifest.permission.POST_NOTIFICATIONS + ) + } + } + } + + @After + fun restoreUsageAccess() { + originalUsageAccessMode?.let { + shell("appops set $packageName GET_USAGE_STATS $it") + } + } + + private fun assertSafeContent(activity: Activity) { + val root = activity.findViewById(android.R.id.content).getChildAt(0) + val insets = requireNotNull(ViewCompat.getRootWindowInsets(root)) + val safe = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + ) + val position = IntArray(2) + root.getLocationOnScreen(position) + assertTrue("Content top must clear status bar/cutout", position[1] + root.paddingTop >= safe.top) + assertTrue("Content left must clear cutout", position[0] + root.paddingLeft >= safe.left) + assertTrue("Content bottom must clear navigation", position[1] + root.height - root.paddingBottom <= device.displayHeight - safe.bottom) + assertTrue("Content right must clear cutout", position[0] + root.width - root.paddingRight <= device.displayWidth - safe.right) + assertTrue("Light native surface needs dark status icons", + WindowCompat.getInsetsController(activity.window, root).isAppearanceLightStatusBars) + + val padding = listOf(root.paddingLeft, root.paddingTop, root.paddingRight, root.paddingBottom) + repeat(3) { ViewCompat.dispatchApplyWindowInsets(root, insets) } + assertEquals("Redispatch must not accumulate padding", padding, + listOf(root.paddingLeft, root.paddingTop, root.paddingRight, root.paddingBottom)) + } + + private fun checkWindow(type: Class) { + ActivityScenario.launch(type).use { scenario -> + device.waitForIdle() + scenario.onActivity { assertSafeContent(it) } + scenario.recreate() + device.waitForIdle() + scenario.onActivity { assertSafeContent(it) } + } + } + + @Test fun onboardingClearsSystemBarsAfterRecreation() = checkWindow(OnboardingActivity::class.java) + + @Test fun authClearsSystemBarsAfterRecreation() = checkWindow(AuthSettingsActivity::class.java) + + @Test fun syncToggleReceivesRealTap() { + val context = ApplicationProvider.getApplicationContext() + val prefs = AWPreferences(context) + val original = prefs.isSyncEnabled() + try { + ActivityScenario.launch(SyncSettingsActivity::class.java).use { scenario -> + val toggle = device.wait(Until.findObject(By.res(context.packageName, "switch_sync_enabled")), 5000) + assertNotNull("Sync switch must be visible", toggle) + scenario.onActivity { assertSafeContent(it) } + toggle.click() + device.wait( + Until.findObject(By.res(context.packageName, "switch_sync_enabled").checked(!original)), + 5000 + ) + assertEquals("A screen tap must change the persisted setting", !original, prefs.isSyncEnabled()) + scenario.recreate() + device.waitForIdle() + scenario.onActivity { assertSafeContent(it) } + assertEquals(!original, prefs.isSyncEnabled()) + device.findObject(By.res(context.packageName, "switch_sync_enabled")).click() + device.wait( + Until.findObject(By.res(context.packageName, "switch_sync_enabled").checked(original)), + 5000 + ) + assertEquals(original, prefs.isSyncEnabled()) + } + } finally { + prefs.setSyncEnabled(original) + } + } + + @Test fun drawerAndWebContentClearSystemBars() { + val context = ApplicationProvider.getApplicationContext() + val prefs = AWPreferences(context) + val wasFirstTime = prefs.isFirstTime() + prefs.setFirstTimeRunFlag() + try { + ActivityScenario.launch(MainActivity::class.java).use { scenario -> + device.waitForIdle() + scenario.onActivity { + assertSafeContent(it) + it.findViewById(R.id.drawer_layout).openDrawer(GravityCompat.START, false) + } + device.waitForIdle() + scenario.onActivity { + val logo = it.findViewById(R.id.imageView) + val bounds = android.graphics.Rect() + assertTrue(logo.getGlobalVisibleRect(bounds)) + assertEquals("The logo must not be clipped", logo.height, bounds.height()) + } + scenario.recreate() + device.waitForIdle() + scenario.onActivity { assertSafeContent(it) } + } + } finally { + if (wasFirstTime) prefs.resetFirstTimeRunFlag() + } + } +} diff --git a/mobile/src/main/java/net/activitywatch/android/AuthSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/AuthSettingsActivity.kt index 2bbf68e8..b2953b72 100644 --- a/mobile/src/main/java/net/activitywatch/android/AuthSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/AuthSettingsActivity.kt @@ -40,6 +40,7 @@ class AuthSettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_auth_settings) + applySafeWindowInsets() supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) diff --git a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt index 967af7c0..07978d73 100644 --- a/mobile/src/main/java/net/activitywatch/android/MainActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/MainActivity.kt @@ -110,6 +110,7 @@ class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelecte binding = ActivityMainBinding.inflate(layoutInflater) val view = binding.root setContentView(view) + applySafeWindowInsets() // Set up alarm to send heartbeats val usw = UsageStatsWatcher(this) diff --git a/mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt b/mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt index 2e5c3c40..e1dcdf5e 100644 --- a/mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/OnboardingActivity.kt @@ -33,6 +33,7 @@ class OnboardingActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_onboarding) + applySafeWindowInsets() val viewPager = findViewById(R.id.viewPager) val tabLayout = findViewById(R.id.tabLayout) @@ -199,4 +200,4 @@ class PermissionsFragment : Fragment() { view?.findViewById(R.id.checkmarkExactAlarm)?.setImageResource(if(exactAlarmPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x) view?.findViewById(R.id.checkmarkNotification)?.setImageResource(if(notificationPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x) } -} \ No newline at end of file +} diff --git a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt index 91babbeb..9c0ab953 100644 --- a/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt +++ b/mobile/src/main/java/net/activitywatch/android/SyncSettingsActivity.kt @@ -103,6 +103,7 @@ class SyncSettingsActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_sync_settings) + applySafeWindowInsets() supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) diff --git a/mobile/src/main/java/net/activitywatch/android/WindowInsets.kt b/mobile/src/main/java/net/activitywatch/android/WindowInsets.kt new file mode 100644 index 00000000..f7b53f94 --- /dev/null +++ b/mobile/src/main/java/net/activitywatch/android/WindowInsets.kt @@ -0,0 +1,33 @@ +package net.activitywatch.android + +import android.graphics.Color +import android.view.ViewGroup +import androidx.activity.SystemBarStyle +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +/** Call after setContentView. The activity root owns safe areas for all its children. */ +internal fun AppCompatActivity.applySafeWindowInsets() { + // The native activities use a light theme even when the device uses dark mode. + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.BLACK), + navigationBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.BLACK), + ) + val root = findViewById(android.R.id.content).getChildAt(0) + val left = root.paddingLeft + val top = root.paddingTop + val right = root.paddingRight + val bottom = root.paddingBottom + ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets -> + val safe = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() + or WindowInsetsCompat.Type.ime() + ) + view.setPadding(left + safe.left, top + safe.top, right + safe.right, bottom + safe.bottom) + // In particular, DrawerLayout/NavigationView must not apply these a second time. + WindowInsetsCompat.CONSUMED + } + ViewCompat.requestApplyInsets(root) +} diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml index 4695b186..d523f742 100644 --- a/mobile/src/main/res/layout/activity_main.xml +++ b/mobile/src/main/res/layout/activity_main.xml @@ -14,7 +14,6 @@ android:id="@+id/drawer_layout" android:layout_width="match_parent" android:layout_height="match_parent" - android:fitsSystemWindows="true" tools:openDrawer="start"> diff --git a/mobile/src/main/res/values-v35/styles.xml b/mobile/src/main/res/values-v35/styles.xml deleted file mode 100644 index f813aa67..00000000 --- a/mobile/src/main/res/values-v35/styles.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - -