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
@@ -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<ViewGroup>(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 <A : Activity> checkWindow(type: Class<A>) {
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<android.content.Context>()
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<android.content.Context>()
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<DrawerLayout>(R.id.drawer_layout).openDrawer(GravityCompat.START, false)
}
device.waitForIdle()
scenario.onActivity {
val logo = it.findViewById<View>(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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class OnboardingActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_onboarding)
applySafeWindowInsets()

val viewPager = findViewById<ViewPager2>(R.id.viewPager)
val tabLayout = findViewById<TabLayout>(R.id.tabLayout)
Expand Down Expand Up @@ -199,4 +200,4 @@ class PermissionsFragment : Fragment() {
view?.findViewById<ImageView>(R.id.checkmarkExactAlarm)?.setImageResource(if(exactAlarmPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
view?.findViewById<ImageView>(R.id.checkmarkNotification)?.setImageResource(if(notificationPermissionGranted) R.drawable.ic_checkmark else R.drawable.ic_x)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions mobile/src/main/java/net/activitywatch/android/WindowInsets.kt
Original file line number Diff line number Diff line change
@@ -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<ViewGroup>(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)
}
2 changes: 0 additions & 2 deletions mobile/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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">

<include
Expand All @@ -27,7 +26,6 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header_main"
app:menu="@menu/activity_main_drawer"/>

Expand Down
20 changes: 0 additions & 20 deletions mobile/src/main/res/values-v35/styles.xml

This file was deleted.

Loading