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
31 changes: 24 additions & 7 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,16 @@ jobs:
:app:checkDebugAarMetadata :app:assembleDebug :app:assembleDebugAndroidTest
:app:lintDebug :app:testDebugUnitTest

api27:
name: API 27 package smoke
devices:
name: API ${{ matrix.api-level }} legacy compatibility
strategy:
fail-fast: false
matrix:
include:
- api-level: 27
target: default
- api-level: 36
target: google_apis
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
Expand Down Expand Up @@ -99,13 +107,22 @@ jobs:
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Package instrumentation on clean API 27
- name: Legacy instrumentation on clean emulator
uses: ReactiveCircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2
with:
api-level: 27
target: default
api-level: ${{ matrix.api-level }}
target: ${{ matrix.target }}
arch: x86_64
avd-name: foundation-api27
avd-name: foundation-api${{ matrix.api-level }}
force-avd-creation: true
emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -wipe-data
script: ./gradlew --no-daemon --stacktrace :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.acetimetracker.ExampleInstrumentedTest
script: ./gradlew --no-daemon --stacktrace :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.example.acetimetracker.ExampleInstrumentedTest,com.example.acetimetracker.LegacyWindowInsetsTest,com.example.acetimetracker.MainActivityBackTest,com.example.acetimetracker.LegacyWindowRuntimeTest

- name: Retain device reports
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: legacy-api${{ matrix.api-level }}
path: |
app/build/reports/androidTests/connected/
app/build/outputs/androidTest-results/connected/
3 changes: 1 addition & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,7 @@ android {
defaultConfig {
applicationId = "com.example.acetimetracker"
minSdk = 27
// Task 2 owns the target-36 system-bar and Back compatibility change.
targetSdk = 34
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package com.example.acetimetracker

import android.graphics.Rect
import android.os.Build
import android.widget.FrameLayout
import androidx.core.graphics.Insets
import androidx.core.view.DisplayCutoutCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.test.platform.app.InstrumentationRegistry
import com.example.acetimetracker.core.ui.installLegacyWindowInsets
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LegacyWindowInsetsTest {
private val instrumentation = InstrumentationRegistry.getInstrumentation()

// Removing the listener, adding to current padding, summing IME and bars,
// or consuming unrelated inset types must break these real View assertions.
@Test
@SdkSuppress(minSdkVersion = 30)
fun appliesSystemBarsAndCutoutOnce() = withContent { view ->
ViewCompat.dispatchApplyWindowInsets(view, fixture())
assertPadding(view, 33, 29, 7, 27)
}

@Test
@SdkSuppress(minSdkVersion = 30)
fun imeUsesMaximumBottomInsetAndResets() = withContent { view ->
ViewCompat.dispatchApplyWindowInsets(view, fixture(imeBottom = 250))
assertEquals(261, view.paddingBottom)
ViewCompat.dispatchApplyWindowInsets(view, fixture())
assertEquals(27, view.paddingBottom)
}

@Test
@SdkSuppress(minSdkVersion = 30)
fun redispatchAndReinstallDoNotAccumulate() = withContent { view ->
ViewCompat.dispatchApplyWindowInsets(view, fixture())
ViewCompat.dispatchApplyWindowInsets(view, fixture())
installLegacyWindowInsets(view)
ViewCompat.dispatchApplyWindowInsets(view, fixture())
assertPadding(view, 33, 29, 7, 27)
}

@Test
fun hiddenBarsRestoreInitialPadding() = withContent { view ->
ViewCompat.dispatchApplyWindowInsets(view, if (Build.VERSION.SDK_INT >= 30) fixture(250) else bars())
ViewCompat.dispatchApplyWindowInsets(view, WindowInsetsCompat.Builder().build())
assertPadding(view, 3, 5, 7, 11)
}

@Test
fun systemBarsRedispatchAndReinstallDoNotAccumulate() = withContent { view ->
ViewCompat.dispatchApplyWindowInsets(view, bars())
assertPadding(view, 3, 29, 7, 27)
installLegacyWindowInsets(view)
ViewCompat.dispatchApplyWindowInsets(view, bars())
ViewCompat.dispatchApplyWindowInsets(view, bars())
assertPadding(view, 3, 29, 7, 27)
}

@Test
@SdkSuppress(minSdkVersion = 30)
fun forwardsUnhandledInsetsToChildren() = withContent { view ->
val child = FrameLayout(view.context)
view.addView(child)
var delivered: WindowInsetsCompat? = null
ViewCompat.setOnApplyWindowInsetsListener(child) { _, insets ->
delivered = insets
insets
}
val input = WindowInsetsCompat.Builder(fixture(imeBottom = 250))
.setInsets(WindowInsetsCompat.Type.systemGestures(), Insets.of(12, 0, 13, 16))
.build()
ViewCompat.dispatchApplyWindowInsets(view, input)
assertEquals(Insets.NONE, delivered!!.getInsets(WindowInsetsCompat.Type.systemBars()))
assertEquals(Insets.NONE, delivered!!.getInsets(WindowInsetsCompat.Type.displayCutout()))
assertEquals(Insets.NONE, delivered!!.getInsets(WindowInsetsCompat.Type.ime()))
assertEquals(Insets.of(12, 0, 13, 16), delivered!!.getInsets(WindowInsetsCompat.Type.systemGestures()))
}

private fun withContent(block: (FrameLayout) -> Unit) {
instrumentation.runOnMainSync {
val view = FrameLayout(instrumentation.targetContext)
view.setPadding(3, 5, 7, 11)
installLegacyWindowInsets(view)
block(view)
}
}

private fun bars() = WindowInsetsCompat.Builder()
.setInsets(WindowInsetsCompat.Type.systemBars(), Insets.of(0, 24, 0, 16))
.build()

private fun fixture(imeBottom: Int = 0) = WindowInsetsCompat.Builder(bars())
.setDisplayCutout(DisplayCutoutCompat(Rect(30, 0, 0, 0), emptyList()))
.setInsets(WindowInsetsCompat.Type.ime(), Insets.of(0, 0, 0, imeBottom))
.build()

private fun assertPadding(view: FrameLayout, left: Int, top: Int, right: Int, bottom: Int) {
assertEquals(left, view.paddingLeft)
assertEquals(top, view.paddingTop)
assertEquals(right, view.paddingRight)
assertEquals(bottom, view.paddingBottom)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.example.acetimetracker

import android.os.SystemClock
import android.util.Log
import android.view.ViewGroup
import android.widget.EditText
import android.view.inputmethod.InputMethodManager
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.Espresso.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

/** Device coverage supplements synthetic fixtures, including pre-30 IME delivery. */
@RunWith(AndroidJUnit4::class)
class LegacyWindowRuntimeTest {
@Test
fun focusedPasswordRemainsAboveKeyboard() = verifyFocusedField(Login::class.java, R.id.etPassword)

@Test
fun focusedConfirmationRemainsAboveKeyboard() = verifyFocusedField(Registration::class.java, R.id.etConfirmPassword)

private fun <A : AppCompatActivity> verifyFocusedField(activityClass: Class<A>, fieldId: Int) {
ActivityScenario.launch(activityClass).use { scenario ->
closeSoftKeyboard()
onView(withId(fieldId)).check(matches(isDisplayed()))
var windowBottom = 0
scenario.onActivity {
windowBottom = bottomOnScreen(it.window.decorView)
val password = it.findViewById<EditText>(fieldId)
password.requestFocus()
(it.getSystemService(android.content.Context.INPUT_METHOD_SERVICE) as InputMethodManager)
.showSoftInput(password, InputMethodManager.SHOW_IMPLICIT)
}
await("focused password fully visible above keyboard") {
var ready = false
scenario.onActivity {
val password = it.findViewById<EditText>(fieldId)
val insets = ViewCompat.getRootWindowInsets(it.window.decorView)!!
val ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val bottom = bottomOnScreen(password)
ready = insets.isVisible(WindowInsetsCompat.Type.ime()) && password.hasFocus() &&
bottom <= windowBottom - ime
Log.i("P1Runtime", "Focused password: bottom=$bottom, keyboardTop=${windowBottom - ime}, focus=${password.hasFocus()}")
}
ready
}
}
}

@Test
fun realKeyboardShowHideRestoresContentPadding() {
ActivityScenario.launch(Login::class.java).use { scenario ->
var initialBottom = 0
var windowBottom = 0
var initialUsableBottom = 0
closeSoftKeyboard()
await("initial layout and bar insets settled") {
var ready = false
scenario.onActivity {
val decor = it.window.decorView
val content = it.findViewById<ViewGroup>(android.R.id.content)
val insets = ViewCompat.getRootWindowInsets(decor)
ready = decor.hasWindowFocus() && content.isLaidOut && insets != null &&
!insets.isVisible(WindowInsetsCompat.Type.ime()) &&
content.paddingBottom == insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
if (ready) {
initialBottom = content.paddingBottom
windowBottom = bottomOnScreen(decor)
initialUsableBottom = bottomOnScreen(content.getChildAt(0))
}
}
ready
}
onView(withId(R.id.etEmail)).perform(click())
await("keyboard visible and content above it") {
var ready = false
scenario.onActivity {
val content = it.findViewById<ViewGroup>(android.R.id.content)
val insets = ViewCompat.getRootWindowInsets(it.window.decorView)!!
val ime = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom
val usableBottom = bottomOnScreen(content.getChildAt(0))
ready = insets.isVisible(WindowInsetsCompat.Type.ime()) && ime > initialBottom &&
content.paddingBottom == ime && usableBottom == windowBottom - ime
Log.i("P1Runtime", "IME check: bottom=$ime, contentHeight=${content.height}, usableBottom=$usableBottom, expected=${windowBottom - ime}")
}
ready
}
systemBack()
await("keyboard hidden and original bar padding restored") {
var ready = false
scenario.onActivity {
val content = it.findViewById<ViewGroup>(android.R.id.content)
val insets = ViewCompat.getRootWindowInsets(it.window.decorView)!!
ready = !insets.isVisible(WindowInsetsCompat.Type.ime()) && content.paddingBottom == initialBottom &&
bottomOnScreen(content.getChildAt(0)) == initialUsableBottom
if (ready) Log.i("P1Runtime", "IME hidden: bottom=${content.paddingBottom}, contentHeight=${content.height}")
}
ready
}
assertEquals(Lifecycle.State.RESUMED, scenario.state)
}
}

@Test
fun registrationLoginLinkAndSystemBackPreserveFormNavigation() {
ActivityScenario.launch(Registration::class.java).use { scenario ->
onView(withId(R.id.etUsername)).check(matches(isDisplayed()))
onView(withId(R.id.tvLoginLink)).perform(click())
closeSoftKeyboard()
onView(withId(R.id.btnLogin)).check(matches(isDisplayed()))
systemBack()
await("Registration resumed after system Back transition") {
var ready = false
if (scenario.state == Lifecycle.State.RESUMED) {
scenario.onActivity { ready = it.window.decorView.hasWindowFocus() }
}
ready
}
onView(withId(R.id.etUsername)).check(matches(isDisplayed()))
}
}

@Test
fun visibleBackReturnsFromAboutToDrawerHost() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.onActivity {
it.findViewById<DrawerLayout>(R.id.drawer_layout).openDrawer(GravityCompat.START, false)
}
onView(withId(R.id.nav_about)).perform(click())
onView(withId(R.id.btnBack)).check(matches(isDisplayed())).perform(click())
await("drawer host resumed") { scenario.state == Lifecycle.State.RESUMED }
scenario.onActivity { assertTrue(it.findViewById<DrawerLayout>(R.id.drawer_layout).isShown) }
}
}

private fun systemBack() {
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("input keyevent KEYCODE_BACK").use { descriptor ->
java.io.FileInputStream(descriptor.fileDescriptor).use { it.readBytes() }
}
}

private fun bottomOnScreen(view: android.view.View): Int {
val location = IntArray(2)
view.getLocationOnScreen(location)
return location[1] + view.height
}

private fun await(description: String, condition: () -> Boolean) {
val deadline = SystemClock.uptimeMillis() + 10000
while (SystemClock.uptimeMillis() < deadline) {
if (condition()) return
SystemClock.sleep(50)
}
assertTrue("Timed out waiting for $description", condition())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.example.acetimetracker

import android.os.SystemClock
import androidx.core.view.GravityCompat
import androidx.drawerlayout.widget.DrawerLayout
import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class MainActivityBackTest {
@Test
fun backClosesDrawerBeforeFinishing() = verifyBack { scenario ->
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
}

@Test
fun systemBackClosesDrawerBeforeFinishing() = verifyBack {
InstrumentationRegistry.getInstrumentation().uiAutomation
.executeShellCommand("input keyevent KEYCODE_BACK").use { descriptor ->
java.io.FileInputStream(descriptor.fileDescriptor).use { it.readBytes() }
}
}

private fun verifyBack(back: (ActivityScenario<MainActivity>) -> Unit) {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var activity: MainActivity
scenario.onActivity {
activity = it
it.findViewById<DrawerLayout>(R.id.drawer_layout).openDrawer(GravityCompat.START, false)
}
back(scenario)
val deadline = SystemClock.uptimeMillis() + 5000
var drawerOpen = true
while (drawerOpen && SystemClock.uptimeMillis() < deadline) {
scenario.onActivity {
drawerOpen = it.findViewById<DrawerLayout>(R.id.drawer_layout).isDrawerOpen(GravityCompat.START)
assertFalse(it.isFinishing)
}
SystemClock.sleep(20)
}
assertFalse(drawerOpen)
back(scenario)
while (scenario.state != Lifecycle.State.DESTROYED && SystemClock.uptimeMillis() < deadline) {
SystemClock.sleep(20)
}
assertEquals(Lifecycle.State.DESTROYED, scenario.state)
assertFalse(activity.isChangingConfigurations)
}
}
}
Loading
Loading