From c7dd18153b58b004d50993b6561f7fa09e75dce9 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Sat, 11 Jul 2026 23:09:25 -0400 Subject: [PATCH 1/2] feat: modernize screen timeout tile --- PROJECT_GOAL.md | 73 +++---------------- README.md | 4 +- app/build.gradle.kts | 9 ++- app/src/main/AndroidManifest.xml | 8 +- .../screentimeouttile/MainActivity.kt | 33 ++++++++- .../screentimeouttile/SetupUiState.kt | 8 +- .../screentimeouttile/TimeoutDurationLabel.kt | 2 +- .../TimeoutLabelFormatter.kt | 10 ++- .../screentimeouttile/TimeoutTileService.kt | 24 +++--- .../res/drawable/ic_launcher_foreground.xml | 30 -------- .../res/drawable/ic_launcher_monochrome.xml | 11 +++ app/src/main/res/drawable/ic_qs_timeout.xml | 60 --------------- .../main/res/drawable/ic_qs_timeout_long.xml | 18 +---- .../res/drawable/ic_qs_timeout_permission.xml | 17 +---- .../main/res/drawable/ic_qs_timeout_short.xml | 20 +---- app/src/main/res/layout/activity_main.xml | 6 +- .../res/mipmap-anydpi-v26/ic_launcher.xml | 3 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 3 +- app/src/main/res/values/strings.xml | 12 ++- app/src/main/res/xml/backup_rules.xml | 15 +--- .../main/res/xml/data_extraction_rules.xml | 20 ++--- .../screentimeouttile/SetupUiStateTest.kt | 13 ++++ .../TimeoutDurationLabelTest.kt | 10 +++ docs/modernization-summary.md | 5 +- docs/release-todo.md | 49 +++++++++++++ gradle/libs.versions.toml | 4 +- 26 files changed, 203 insertions(+), 264 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml delete mode 100644 app/src/main/res/drawable/ic_qs_timeout.xml create mode 100644 docs/release-todo.md diff --git a/PROJECT_GOAL.md b/PROJECT_GOAL.md index 90edd94..2c2ff0a 100644 --- a/PROJECT_GOAL.md +++ b/PROJECT_GOAL.md @@ -51,72 +51,19 @@ A minimal Android app that adds a Quick Settings tile. Tapping the tile toggles --- ## Manifest -```xml - - - - - - - - - - - - - - - - - - - - - -``` +The production manifest is the source of truth. It declares only the protected +`WRITE_SETTINGS` permission, the exported launcher activity, and the exported +tile service guarded by `BIND_QUICK_SETTINGS_TILE`. App backup is disabled +because the app stores no user data. --- -## Example Implementation -```kotlin -@RequiresApi(Build.VERSION_CODES.N) -class TimeoutTileService : TileService() { - - override fun onClick() { - if (!Settings.System.canWrite(this)) { - val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { - data = Uri.parse("package:$packageName") - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - startActivity(intent) - return - } - - val current = Settings.System.getInt( - contentResolver, - Settings.System.SCREEN_OFF_TIMEOUT, - 30000 - ) - - val newTimeout = if (current > 30000) 30000 else 600000 // 30 s vs 10 min - Settings.System.putInt(contentResolver, Settings.System.SCREEN_OFF_TIMEOUT, newTimeout) - - qsTile.state = if (newTimeout > 30000) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE - qsTile.updateTile() - } -} -``` +## Implementation +`TimeoutTileService` owns the Android tile lifecycle, while +`ScreenTimeoutController`, `ScreenTimeoutSettingsStore`, and +`TimeoutTogglePolicy` keep settings access and toggle behavior independently +testable. Refer to the production Kotlin sources instead of duplicating an +implementation sample here. --- diff --git a/README.md b/README.md index 0cc7cf8..88caee5 100644 --- a/README.md +++ b/README.md @@ -21,10 +21,10 @@ Android has the screen timeout setting buried in system settings. This project m ## Requirements - Android 7.0 or newer, API 24+ -- Android Studio Giraffe or newer recommended +- Android Studio Narwhal 4 Feature Drop or newer recommended - Android Gradle Plugin 8.13.0 - Kotlin 2.0.21 -- Java 11 toolchain +- JDK 17 ## Install A Debug Build diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3737c34..b56f649 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -19,7 +19,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -27,11 +28,11 @@ android { } } compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ecf40d9..133fdb1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,7 +7,7 @@ tools:ignore="ProtectedPermissions" /> + diff --git a/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt b/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt index 749c13e..bf45957 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt @@ -4,7 +4,6 @@ import android.app.StatusBarManager import android.content.ComponentName import android.content.Intent import android.graphics.drawable.Icon -import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings @@ -12,6 +11,11 @@ import android.widget.Button import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity +import androidx.core.net.toUri +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding class MainActivity : AppCompatActivity() { @@ -31,7 +35,9 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + WindowCompat.enableEdgeToEdge(window) setContentView(R.layout.activity_main) + configureSystemBars() controller = ScreenTimeoutController(this) statusView = findViewById(R.id.permissionStatus) @@ -45,6 +51,25 @@ class MainActivity : AppCompatActivity() { tileButton.setOnClickListener { requestTilePlacement() } } + private fun configureSystemBars() { + WindowCompat.getInsetsController(window, window.decorView).apply { + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } + ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.screenContent)) { view, insets -> + val safeDrawing = insets.getInsets( + WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(), + ) + view.updatePadding( + left = safeDrawing.left, + top = safeDrawing.top, + right = safeDrawing.right, + bottom = safeDrawing.bottom, + ) + insets + } + } + override fun onResume() { super.onResume() updateUi() @@ -52,7 +77,7 @@ class MainActivity : AppCompatActivity() { private fun openWriteSettings() { val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { - data = Uri.parse("package:$packageName") + data = "package:$packageName".toUri() } permissionLauncher.launch(intent) } @@ -107,10 +132,10 @@ class MainActivity : AppCompatActivity() { val statusBarManager = getSystemService(StatusBarManager::class.java) val componentName = ComponentName(this, TimeoutTileService::class.java) - val icon = Icon.createWithResource(this, R.drawable.ic_qs_timeout) + val icon = Icon.createWithResource(this, R.drawable.ic_qs_timeout_long) statusBarManager.requestAddTileService( componentName, - getString(R.string.app_name), + getString(R.string.tile_label), icon, mainExecutor, ) { result -> diff --git a/app/src/main/java/com/stonecode/screentimeouttile/SetupUiState.kt b/app/src/main/java/com/stonecode/screentimeouttile/SetupUiState.kt index 95e0609..5b1280a 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/SetupUiState.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/SetupUiState.kt @@ -25,11 +25,9 @@ data class SetupUiState( } else { SetupAction.MANUAL_ADD_TILE }, - timeoutStatus = TimeoutStatus.Available( - TimeoutDurationLabel.fromMillis( - currentTimeoutMs ?: TimeoutTogglePolicy.SHORT_TIMEOUT_MS, - ), - ), + timeoutStatus = currentTimeoutMs?.let { timeoutMs -> + TimeoutStatus.Available(TimeoutDurationLabel.fromMillis(timeoutMs)) + } ?: TimeoutStatus.Unavailable, canRequestTilePlacement = canRequestTilePlacement, ) } diff --git a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutDurationLabel.kt b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutDurationLabel.kt index 8aae8c6..6ce7f62 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutDurationLabel.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutDurationLabel.kt @@ -9,7 +9,7 @@ sealed interface TimeoutDurationLabel { companion object { fun fromMillis(timeoutMs: Int): TimeoutDurationLabel { val seconds = timeoutMs / 1_000 - return if (seconds >= 60) { + return if (seconds >= 60 && seconds % 60 == 0) { Minutes(seconds / 60) } else { Seconds(seconds) diff --git a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutLabelFormatter.kt b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutLabelFormatter.kt index df0455d..3ee6d5e 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutLabelFormatter.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutLabelFormatter.kt @@ -10,12 +10,14 @@ object TimeoutLabelFormatter { fun format(context: Context, label: TimeoutDurationLabel): String { return when (label) { - is TimeoutDurationLabel.Minutes -> context.getString( - R.string.tile_subtitle_minutes, + is TimeoutDurationLabel.Minutes -> context.resources.getQuantityString( + R.plurals.tile_subtitle_minutes, + label.value, label.value, ) - is TimeoutDurationLabel.Seconds -> context.getString( - R.string.tile_subtitle_seconds, + is TimeoutDurationLabel.Seconds -> context.resources.getQuantityString( + R.plurals.tile_subtitle_seconds, + label.value, label.value, ) } diff --git a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt index be11842..d2d5e0d 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt @@ -1,16 +1,15 @@ package com.stonecode.screentimeouttile +import android.annotation.SuppressLint import android.app.PendingIntent import android.content.Intent import android.graphics.drawable.Icon -import android.net.Uri import android.os.Build import android.provider.Settings import android.service.quicksettings.Tile import android.service.quicksettings.TileService -import androidx.annotation.RequiresApi +import androidx.core.net.toUri -@RequiresApi(Build.VERSION_CODES.N) class TimeoutTileService : TileService() { private val controller by lazy { ScreenTimeoutController(this) } @@ -39,9 +38,10 @@ class TimeoutTileService : TileService() { } } + @SuppressLint("StartActivityAndCollapseDeprecated") private fun requestWriteSettings() { val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { - data = Uri.parse("package:$packageName") + data = "package:$packageName".toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -60,7 +60,6 @@ class TimeoutTileService : TileService() { private fun refreshTileState() { val hasPermission = controller.canModifySystemSettings() - val timeout = runCatching { controller.getCurrentTimeout() }.getOrDefault(controller.shortTimeoutMs) if (!hasPermission) { setTileState( state = Tile.STATE_INACTIVE, @@ -68,7 +67,15 @@ class TimeoutTileService : TileService() { iconResId = R.drawable.ic_qs_timeout_permission, ) } else { - updateTileForTimeout(timeout) + runCatching { controller.getCurrentTimeout() } + .onSuccess(::updateTileForTimeout) + .onFailure { + setTileState( + state = Tile.STATE_UNAVAILABLE, + subtitle = getString(R.string.tile_subtitle_unavailable), + iconResId = R.drawable.ic_qs_timeout_long, + ) + } } } @@ -89,12 +96,11 @@ class TimeoutTileService : TileService() { private fun setTileState(state: Int, subtitle: String?, iconResId: Int) { val tile = qsTile ?: return tile.state = state - tile.label = getString(R.string.app_name) + tile.label = getString(R.string.tile_label) tile.icon = Icon.createWithResource(this, iconResId) + tile.contentDescription = subtitle ?: getString(R.string.app_description) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { tile.subtitle = subtitle - } else { - tile.contentDescription = subtitle ?: getString(R.string.app_description) } tile.updateTile() } diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 7706ab9..0000000 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..3fd5772 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_qs_timeout.xml b/app/src/main/res/drawable/ic_qs_timeout.xml deleted file mode 100644 index ca992bb..0000000 --- a/app/src/main/res/drawable/ic_qs_timeout.xml +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/app/src/main/res/drawable/ic_qs_timeout_long.xml b/app/src/main/res/drawable/ic_qs_timeout_long.xml index c158a53..f9946de 100644 --- a/app/src/main/res/drawable/ic_qs_timeout_long.xml +++ b/app/src/main/res/drawable/ic_qs_timeout_long.xml @@ -4,22 +4,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - + android:fillType="evenOdd" + android:pathData="M6.8,2.8H17.2C18.6,2.8 19.7,3.9 19.7,5.3C19.7,6.6 18.7,7.7 17.4,7.8C15.1,7.9 13.3,9.2 12,11.1C10.7,9.2 8.9,7.9 6.6,7.8C5.3,7.7 4.3,6.6 4.3,5.3C4.3,3.9 5.4,2.8 6.8,2.8ZM8.5,8.8C9.7,9.5 10.8,10.4 12,11.9C13.2,10.4 14.3,9.5 15.5,8.8C14.9,10.3 13.7,11.8 12,13.2C10.3,11.8 9.1,10.3 8.5,8.8ZM6.8,21.2H17.2C18.6,21.2 19.7,20.1 19.7,18.7C19.7,17.4 18.7,16.3 17.4,16.2C15.1,16.1 13.3,14.8 12,12.9C10.7,14.8 8.9,16.1 6.6,16.2C5.3,16.3 4.3,17.4 4.3,18.7C4.3,20.1 5.4,21.2 6.8,21.2ZM8.4,18.9H15.6C14.7,17.4 13.4,16.3 12,15.4C10.6,16.3 9.3,17.4 8.4,18.9Z" /> diff --git a/app/src/main/res/drawable/ic_qs_timeout_permission.xml b/app/src/main/res/drawable/ic_qs_timeout_permission.xml index 26c3893..93f4d08 100644 --- a/app/src/main/res/drawable/ic_qs_timeout_permission.xml +++ b/app/src/main/res/drawable/ic_qs_timeout_permission.xml @@ -4,21 +4,8 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - + android:fillType="evenOdd" + android:pathData="M12,2.5C6.8,2.5 2.5,6.8 2.5,12S6.8,21.5 12,21.5 21.5,17.2 21.5,12 17.2,2.5 12,2.5ZM8.2,10.3V8.9C8.2,6.8 9.9,5.1 12,5.1C14.1,5.1 15.8,6.8 15.8,8.9V10.3H16.4C17.3,10.3 18,11 18,11.9V17C18,17.9 17.3,18.6 16.4,18.6H7.6C6.7,18.6 6,17.9 6,17V11.9C6,11 6.7,10.3 7.6,10.3H8.2ZM10.4,10.3H13.6V8.9C13.6,8 12.9,7.3 12,7.3C11.1,7.3 10.4,8 10.4,8.9V10.3Z" /> diff --git a/app/src/main/res/drawable/ic_qs_timeout_short.xml b/app/src/main/res/drawable/ic_qs_timeout_short.xml index bbe0aab..38eeeaf 100644 --- a/app/src/main/res/drawable/ic_qs_timeout_short.xml +++ b/app/src/main/res/drawable/ic_qs_timeout_short.xml @@ -4,25 +4,11 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - - + android:fillType="evenOdd" + android:pathData="M12,2.5C6.8,2.5 2.5,6.8 2.5,12S6.8,21.5 12,21.5 21.5,17.2 21.5,12 17.2,2.5 12,2.5ZM12,5.8C12.7,5.8 13.2,6.3 13.2,7V12.1L16.2,15.1C16.7,15.6 16.7,16.3 16.2,16.8C15.7,17.3 15,17.3 14.5,16.8L11.1,13.4C10.8,13.2 10.7,12.9 10.7,12.5V7C10.7,6.3 11.3,5.8 12,5.8ZM6.4,13.2H9.7L8.2,17H10.3L6.8,22L7.4,18.1H5.4L6.4,13.2Z" /> + android:pathData="M8,0.8H16V3H8Z" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 51f9fc4..ff01cd6 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,9 +1,9 @@ @@ -47,6 +48,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/permission_needed_status" + android:accessibilityLiveRegion="polite" android:textColor="@color/text_primary_dark" android:textSize="17sp" android:textStyle="bold" /> @@ -66,6 +68,7 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:text="@string/next_action_grant_permission" + android:accessibilityLiveRegion="polite" android:textColor="@color/text_primary_dark" android:textSize="15sp" /> @@ -99,6 +102,7 @@ android:layout_height="wrap_content" android:layout_marginTop="10dp" android:text="@string/tile_request_status_idle" + android:accessibilityLiveRegion="polite" android:textColor="@color/text_secondary_dark" android:textSize="13sp" /> diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index c4a603d..d48a36a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ - \ No newline at end of file + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index c4a603d..d48a36a 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ - \ No newline at end of file + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e962c6..ef3c7b4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,9 +1,17 @@ Screen Timeout Tile Switch between 30 seconds and 10 minutes from Quick Settings. + Screen timeout Grant write settings - %1$d sec timeout - %1$d min timeout + Timeout unavailable + + %1$d second timeout + %1$d seconds timeout + + + %1$d minute timeout + %1$d minutes timeout + Open system settings Add tile Permission required diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 90529f8..92f2b38 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,13 +1,6 @@ - + - + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 0c4f95c..9376762 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,13 @@ - + - + - diff --git a/app/src/test/java/com/stonecode/screentimeouttile/SetupUiStateTest.kt b/app/src/test/java/com/stonecode/screentimeouttile/SetupUiStateTest.kt index 1f91c21..d320c39 100644 --- a/app/src/test/java/com/stonecode/screentimeouttile/SetupUiStateTest.kt +++ b/app/src/test/java/com/stonecode/screentimeouttile/SetupUiStateTest.kt @@ -45,4 +45,17 @@ class SetupUiStateTest { assertEquals(TimeoutStatus.Available(TimeoutDurationLabel.Seconds(30)), state.timeoutStatus) assertFalse(state.canRequestTilePlacement) } + + @Test + fun shouldShowTimeoutUnavailable_whenSettingsReadFails() { + val state = SetupUiState.from( + canWriteSettings = true, + currentTimeoutMs = null, + canRequestTilePlacement = true, + ) + + assertEquals(SetupAction.ADD_TILE, state.primaryAction) + assertEquals(TimeoutStatus.Unavailable, state.timeoutStatus) + assertTrue(state.canRequestTilePlacement) + } } diff --git a/app/src/test/java/com/stonecode/screentimeouttile/TimeoutDurationLabelTest.kt b/app/src/test/java/com/stonecode/screentimeouttile/TimeoutDurationLabelTest.kt index b254264..25aec02 100644 --- a/app/src/test/java/com/stonecode/screentimeouttile/TimeoutDurationLabelTest.kt +++ b/app/src/test/java/com/stonecode/screentimeouttile/TimeoutDurationLabelTest.kt @@ -14,4 +14,14 @@ class TimeoutDurationLabelTest { fun shouldFormatWholeMinutes_whenTimeoutIsAtLeastOneMinute() { assertEquals(TimeoutDurationLabel.Minutes(10), TimeoutDurationLabel.fromMillis(600_000)) } + + @Test + fun shouldFormatOneMinute_whenTimeoutIsExactlyOneMinute() { + assertEquals(TimeoutDurationLabel.Minutes(1), TimeoutDurationLabel.fromMillis(60_000)) + } + + @Test + fun shouldPreserveSeconds_whenTimeoutIsNotAWholeMinute() { + assertEquals(TimeoutDurationLabel.Seconds(90), TimeoutDurationLabel.fromMillis(90_000)) + } } diff --git a/docs/modernization-summary.md b/docs/modernization-summary.md index 06b9cfc..9913583 100644 --- a/docs/modernization-summary.md +++ b/docs/modernization-summary.md @@ -6,6 +6,9 @@ - Reworked the launcher setup screen into a compact dark UI that fits the tested phone at `font_scale=1.3`. - Added Android 13+ direct Quick Settings tile placement from the setup screen. - Added custom Quick Settings tile icons for short timeout, long timeout, and missing-permission states. +- Added target-SDK-36 edge-to-edge inset handling and accessible live status announcements. +- Added a dedicated short tile label, toggle metadata, and explicit unavailable state for read failures. +- Updated the project to JDK 17, AppCompat 1.7.1, and Material Components 1.14.0. - Replaced placeholder unit/instrumentation tests with focused behavior and launch smoke tests. - Updated README and project goal notes to match the current setup flow. @@ -15,7 +18,7 @@ - ADB serial: `RFCX61FNYPY` - Package: `com.stonecode.screentimeouttile` - Installed version: `versionName=1.0`, `versionCode=1` -- Final screenshot: `build/screenshots/final-ui.png` +- Visual authority: installed app on the device; local screenshots are intentionally not tracked. ## Checks Run diff --git a/docs/release-todo.md b/docs/release-todo.md new file mode 100644 index 0000000..1b07993 --- /dev/null +++ b/docs/release-todo.md @@ -0,0 +1,49 @@ +# Release TODO + +## Monroe To Do + +- Pick the public app name: keep `Screen Timeout Tile` or choose a shorter store name. +- Decide whether the Play listing should be free-only, donation-supported, or paid. +- Create/confirm the Google Play Console app entry. +- Create a simple privacy policy page: + - no ads + - no analytics + - no accounts + - no network access + - no personal data collected + - app only changes `Settings.System.SCREEN_OFF_TIMEOUT` after the user grants Modify system settings +- Capture store screenshots on a real phone: + - setup screen before permission + - setup screen after permission + - Quick Settings tile short-timeout mode + - Quick Settings tile long-timeout mode +- Get a screenshot of the low-contrast active Quick Settings tile state if it still happens after the bold icon update. +- Write the store short description and full description. +- Decide whether to publish under `StoneHub`/`StoneCode` branding. +- Create an upload key and enable Play App Signing. + +## Code / Release Engineering To Do + +- Run `./gradlew :app:lintRelease`. +- Run `./gradlew :app:bundleRelease`. +- Verify the generated release artifact. +- Add a signing setup that reads upload-key values from local, untracked Gradle properties. +- Bump `versionCode` and `versionName` before every Play upload. +- Add release notes for the first public build. +- Run manual QA on at least: + - Samsung Android 16 phone + - Android 13+ device for direct tile placement + - Android 7-12 device or emulator for manual tile-placement fallback +- Confirm `WRITE_SETTINGS` behavior survives reboot and tile refresh. +- Confirm the app collects no data and has no accidental network permission. + +## Nice To Have Before Public Launch + +- Better branded launcher foreground art. +- In-app privacy/permission explanation link. +- Tiny FAQ in README and store listing: + - why the permission is needed + - what setting changes + - how to remove the tile + - how to uninstall +- Optional website landing page with privacy policy and screenshots. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 510d978..83a459f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,8 +6,8 @@ junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" testCore = "1.7.0" -appcompat = "1.6.1" -material = "1.10.0" +appcompat = "1.7.1" +material = "1.14.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } From 9d4d91929a6bd2c2a360f0b85e07adffb6056f44 Mon Sep 17 00:00:00 2001 From: StoneHub Date: Tue, 18 Aug 2026 19:06:26 -0400 Subject: [PATCH 2/2] feat: prepare signed v1.1.0 release --- .github/workflows/android.yml | 22 +++ .gitignore | 1 + PRIVACY.md | 13 ++ README.md | 27 ++- app/build.gradle.kts | 63 ++++++- .../MainActivityInstrumentedTest.kt | 1 + app/src/main/AndroidManifest.xml | 5 +- .../screentimeouttile/MainActivity.kt | 30 ++-- .../ScreenTimeoutSettingsStore.kt | 1 - .../screentimeouttile/TimeoutTileService.kt | 51 ++++-- app/src/main/res/layout/activity_main.xml | 154 +++++++++++++++++- app/src/main/res/values/strings.xml | 15 +- .../ScreenTimeoutControllerTest.kt | 13 +- docs/modernization-summary.md | 9 + docs/release-notes-1.1.0.md | 10 ++ docs/release-signing.md | 31 ++++ docs/release-todo.md | 18 +- gradle/wrapper/gradle-wrapper.properties | 1 + release-signing.properties.example | 6 + 19 files changed, 428 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/android.yml create mode 100644 PRIVACY.md create mode 100644 docs/release-notes-1.1.0.md create mode 100644 docs/release-signing.md create mode 100644 release-signing.properties.example diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 0000000..0c49127 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,22 @@ +name: Android CI + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - uses: gradle/actions/setup-gradle@v4 + - name: Test, lint, and assemble + run: ./gradlew :app:testDebugUnitTest :app:lintRelease :app:assembleDebug --console=plain diff --git a/.gitignore b/.gitignore index aa724b7..a14da2b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ .externalNativeBuild .cxx local.properties +release-signing.properties diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..f07e0c2 --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,13 @@ +# Privacy Policy + +Effective August 13, 2026 + +Screen Timeout Tile does not collect, store, share, or sell personal data. + +The app has no internet permission, accounts, advertising, analytics, or tracking. It stores no user content and disables Android backup and device-transfer backup. + +Android requires the **Modify system settings** permission before the app can read or change `Settings.System.SCREEN_OFF_TIMEOUT`. Screen Timeout Tile uses that access only to read the current screen timeout and switch it between 30 seconds and 10 minutes. The app does not use the permission to change any other system setting. + +You can revoke this access at any time from Android Settings. You can also remove the Quick Settings tile or uninstall the app at any time. + +Questions can be opened as an issue in the project’s public GitHub repository. diff --git a/README.md b/README.md index 88caee5..cc03f44 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ Screen Timeout Tile is a small Android app that adds a Quick Settings tile for s It is built for the common Android workflow where you want the screen to stay awake while reading, debugging, cooking, presenting, or using your phone as a reference display, but you still want a quick way back to a short timeout afterward. +## Download + +Download the signed APK from the [latest GitHub release](https://github.com/StoneHub/screen-timeout-tile/releases/latest). Android 7.0 or newer is required. + +Because this app is distributed outside Google Play, Android may ask you to allow installs from the browser or file manager you used to download it. That permission can be turned off again immediately after installation. + ## What It Does - Adds a **Screen Timeout Tile** to Android Quick Settings. @@ -13,6 +19,15 @@ It is built for the common Android workflow where you want the screen to stay aw - Includes a setup screen for permission status, current timeout, and tile placement. - Supports Android 13+ direct tile placement through the system prompt. - Keeps Android 7-12 manual Quick Settings edit instructions available. +- Explains the hourglass, clock/bolt, and permission icons in the setup screen. + +## What The Tile Means + +- **Hourglass, highlighted:** 10-minute timeout. +- **Clock with bolt, dim:** 30-second timeout. +- **Lock:** Modify system settings permission is still needed. + +The tile is never “off.” Each tap switches between the two timeout presets. ## Why This Exists @@ -36,9 +51,11 @@ Android has the screen timeout setting buried in system settings. This project m If your shell cannot find the Android SDK, set the SDK path for the command: ```bash -ANDROID_HOME=/Users/monroe/Library/Android/sdk ANDROID_SDK_ROOT=/Users/monroe/Library/Android/sdk ./gradlew installDebug +ANDROID_HOME="$HOME/Library/Android/sdk" ANDROID_SDK_ROOT="$HOME/Library/Android/sdk" ./gradlew installDebug ``` +Debug builds use the isolated package `com.stonecode.screentimeouttile.debug`, so a dogfood install cannot block a later production-signed update. + ## Setup On A Device 1. Launch **Screen Timeout Tile**. @@ -61,6 +78,8 @@ That permission is required because Android protects writes to `Settings.System. This app does not declare internet access, does not create accounts, does not run analytics, and does not collect personal data. +See the full [privacy policy](PRIVACY.md). + ## Development Run focused JVM tests: @@ -88,6 +107,8 @@ Before publishing a release build, also run: ./gradlew :app:bundleRelease ``` +Release signing can use either the ignored `release-signing.properties` file or Keychain-backed environment variables. See [release signing](docs/release-signing.md). Without signing inputs, Gradle intentionally produces an unsigned release artifact for validation only. + ## Project Structure ```text @@ -111,7 +132,7 @@ app/src/androidTest/java/ Instrumentation smoke tests ## Current Status -The core tile behavior, setup screen, Android 13+ tile request flow, and JVM tests are in place. Release packaging and Play Store publishing are still in progress. +Version 1.1.0 is the first signed GitHub release. Play Store publishing remains a separate future distribution path. ## Contributing @@ -119,7 +140,7 @@ Issues and pull requests are welcome. Useful contributions include: - Testing tile behavior on different Android versions and OEM skins. - Improving accessibility and large-font layout behavior. -- Adding release engineering for signed builds. +- Testing signed releases across additional Android devices and OEM skins. - Tightening the privacy policy and store listing docs. For code changes, keep behavior covered by JVM tests where possible and use instrumentation tests for device-specific setup or tile-placement flows. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b56f649..cf35aa2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,8 +1,49 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) } +val releaseSigningPropertiesFile = rootProject.file("release-signing.properties") +val releaseSigningProperties = Properties().apply { + if (releaseSigningPropertiesFile.isFile) { + releaseSigningPropertiesFile.inputStream().use(::load) + } +} + +fun releaseSigningValue(propertyName: String, environmentName: String): String? = + releaseSigningProperties.getProperty(propertyName)?.takeIf(String::isNotBlank) + ?: providers.environmentVariable(environmentName).orNull?.takeIf(String::isNotBlank) + +val releaseStoreFile = releaseSigningValue( + propertyName = "storeFile", + environmentName = "SCREEN_TIMEOUT_RELEASE_STORE_FILE", +) +val releaseStorePassword = releaseSigningValue( + propertyName = "storePassword", + environmentName = "SCREEN_TIMEOUT_RELEASE_STORE_PASSWORD", +) +val releaseKeyAlias = releaseSigningValue( + propertyName = "keyAlias", + environmentName = "SCREEN_TIMEOUT_RELEASE_KEY_ALIAS", +) +val releaseKeyPassword = releaseSigningValue( + propertyName = "keyPassword", + environmentName = "SCREEN_TIMEOUT_RELEASE_KEY_PASSWORD", +) +val releaseSigningValues = listOf( + releaseStoreFile, + releaseStorePassword, + releaseKeyAlias, + releaseKeyPassword, +) +val hasReleaseSigning = releaseSigningValues.all { it != null } + +check(releaseSigningValues.all { it == null } || hasReleaseSigning) { + "Release signing is partially configured. Provide all four signing values." +} + android { namespace = "com.stonecode.screentimeouttile" compileSdk = 36 @@ -11,16 +52,34 @@ android { applicationId = "com.stonecode.screentimeouttile" minSdk = 24 targetSdk = 36 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.1.0" + manifestPlaceholders["appLabel"] = "Screen Timeout Tile" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + signingConfigs { + if (hasReleaseSigning) { + create("release") { + storeFile = rootProject.file(requireNotNull(releaseStoreFile)) + storePassword = requireNotNull(releaseStorePassword) + keyAlias = requireNotNull(releaseKeyAlias) + keyPassword = requireNotNull(releaseKeyPassword) + } + } + } + buildTypes { + debug { + applicationIdSuffix = ".debug" + versionNameSuffix = "-debug" + manifestPlaceholders["appLabel"] = "Screen Timeout Tile (Debug)" + } release { isMinifyEnabled = true isShrinkResources = true + signingConfig = signingConfigs.findByName("release") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/androidTest/java/com/stonecode/screentimeouttile/MainActivityInstrumentedTest.kt b/app/src/androidTest/java/com/stonecode/screentimeouttile/MainActivityInstrumentedTest.kt index d225332..a5c581a 100644 --- a/app/src/androidTest/java/com/stonecode/screentimeouttile/MainActivityInstrumentedTest.kt +++ b/app/src/androidTest/java/com/stonecode/screentimeouttile/MainActivityInstrumentedTest.kt @@ -25,6 +25,7 @@ class MainActivityInstrumentedTest { fun shouldShowPermissionAndTileCtas_whenActivityLaunches() { ActivityScenario.launch(MainActivity::class.java).use { onView(withId(R.id.permissionButton)).check(matches(isDisplayed())) + onView(withId(R.id.permissionRationale)).check(matches(isDisplayed())) onView(withId(R.id.tileButton)).check(matches(isDisplayed())) onView(withId(R.id.nextAction)).check(matches(isDisplayed())) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 133fdb1..e309298 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,7 +11,7 @@ android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@mipmap/ic_launcher" - android:label="@string/app_name" + android:label="${appLabel}" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/Theme.ScreenTimeoutTile"> @@ -38,6 +38,9 @@ + diff --git a/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt b/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt index bf45957..0d7caab 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/MainActivity.kt @@ -37,6 +37,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) WindowCompat.enableEdgeToEdge(window) setContentView(R.layout.activity_main) + ViewCompat.setAccessibilityHeading(findViewById(R.id.title), true) configureSystemBars() controller = ScreenTimeoutController(this) @@ -79,7 +80,10 @@ class MainActivity : AppCompatActivity() { val intent = Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS).apply { data = "package:$packageName".toUri() } - permissionLauncher.launch(intent) + runCatching { permissionLauncher.launch(intent) } + .onFailure { + nextActionView.text = getString(R.string.permission_request_status_error) + } } private fun updateUi() { @@ -130,16 +134,20 @@ class MainActivity : AppCompatActivity() { return } - val statusBarManager = getSystemService(StatusBarManager::class.java) - val componentName = ComponentName(this, TimeoutTileService::class.java) - val icon = Icon.createWithResource(this, R.drawable.ic_qs_timeout_long) - statusBarManager.requestAddTileService( - componentName, - getString(R.string.tile_label), - icon, - mainExecutor, - ) { result -> - tileRequestStatusView.text = tileRequestStatusText(result) + runCatching { + val statusBarManager = getSystemService(StatusBarManager::class.java) + val componentName = ComponentName(this, TimeoutTileService::class.java) + val icon = Icon.createWithResource(this, R.drawable.ic_qs_timeout_long) + statusBarManager.requestAddTileService( + componentName, + getString(R.string.tile_label), + icon, + mainExecutor, + ) { result -> + tileRequestStatusView.text = tileRequestStatusText(result) + } + }.onFailure { + tileRequestStatusView.text = getString(R.string.tile_request_status_error) } } diff --git a/app/src/main/java/com/stonecode/screentimeouttile/ScreenTimeoutSettingsStore.kt b/app/src/main/java/com/stonecode/screentimeouttile/ScreenTimeoutSettingsStore.kt index 2ac3177..28acd3e 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/ScreenTimeoutSettingsStore.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/ScreenTimeoutSettingsStore.kt @@ -20,7 +20,6 @@ class AndroidScreenTimeoutSettingsStore(context: Context) : ScreenTimeoutSetting override fun getTimeoutMs(): Int = Settings.System.getInt( resolver, Settings.System.SCREEN_OFF_TIMEOUT, - TimeoutTogglePolicy.SHORT_TIMEOUT_MS, ) override fun setTimeoutMs(timeoutMs: Int): Boolean = Settings.System.putInt( diff --git a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt index d2d5e0d..37edf96 100644 --- a/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt +++ b/app/src/main/java/com/stonecode/screentimeouttile/TimeoutTileService.kt @@ -30,11 +30,13 @@ class TimeoutTileService : TileService() { .onSuccess { result -> when (result) { is TimeoutChangeResult.Changed -> updateTileForTimeout(result.timeoutMs) - TimeoutChangeResult.WriteFailed -> refreshTileState() + TimeoutChangeResult.WriteFailed -> showTileError( + getString(R.string.tile_subtitle_write_failed), + ) } } .onFailure { - refreshTileState() + showTileError(getString(R.string.tile_subtitle_unavailable)) } } @@ -44,17 +46,21 @@ class TimeoutTileService : TileService() { data = "package:$packageName".toUri() addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { - val pendingIntent = PendingIntent.getActivity( - this, - 0, - intent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - startActivityAndCollapse(pendingIntent) - } else { - @Suppress("DEPRECATION") - startActivityAndCollapse(intent) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + }.onFailure { + showTileError(getString(R.string.tile_subtitle_settings_unavailable)) } } @@ -96,12 +102,27 @@ class TimeoutTileService : TileService() { private fun setTileState(state: Int, subtitle: String?, iconResId: Int) { val tile = qsTile ?: return tile.state = state - tile.label = getString(R.string.tile_label) + tile.label = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || subtitle == null) { + getString(R.string.tile_label) + } else { + subtitle + } tile.icon = Icon.createWithResource(this, iconResId) - tile.contentDescription = subtitle ?: getString(R.string.app_description) + tile.contentDescription = getString(R.string.tile_content_description) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { tile.subtitle = subtitle } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + tile.stateDescription = subtitle + } tile.updateTile() } + + private fun showTileError(message: String) { + setTileState( + state = Tile.STATE_UNAVAILABLE, + subtitle = message, + iconResId = R.drawable.ic_qs_timeout_permission, + ) + } } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index ff01cd6..9e90053 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -20,7 +20,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/app_name" - android:accessibilityHeading="true" android:textColor="@color/text_primary_dark" android:textSize="25sp" android:textStyle="bold" /> @@ -72,6 +71,16 @@ android:textColor="@color/text_primary_dark" android:textSize="15sp" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Screen timeout Grant write settings Timeout unavailable + Could not change timeout + Settings unavailable + Screen timeout toggle %1$d second timeout %1$d seconds timeout @@ -13,6 +16,8 @@ %1$d minutes timeout Open system settings + Android’s Modify system settings access is required only to read and change the screen timeout. This app does not change any other setting. + Could not open Modify system settings. Open Android Settings, search for “Modify system settings,” and allow Screen Timeout Tile. Add tile Permission required Ready to toggle screen timeout @@ -27,5 +32,13 @@ Tile was not added. Use the manual Quick Settings edit flow. Tile placement request failed. Use the manual Quick Settings edit flow. Direct tile placement is available on Android 13 and newer. - After setup, tap the tile to switch between the short and long timeout modes. + What the tile means + The tile is never “off.” It always shows one of two screen timeout presets. + Hourglass · 10 minutes + Highlighted tile. Keeps the screen on longer. + Clock with bolt · 30 seconds + Dim tile. Returns to the short timeout. + Lock · Permission needed + Tap the tile, then allow Modify system settings. + After setup, each tap switches between the 30-second and 10-minute timeout. diff --git a/app/src/test/java/com/stonecode/screentimeouttile/ScreenTimeoutControllerTest.kt b/app/src/test/java/com/stonecode/screentimeouttile/ScreenTimeoutControllerTest.kt index 59050b2..44ee991 100644 --- a/app/src/test/java/com/stonecode/screentimeouttile/ScreenTimeoutControllerTest.kt +++ b/app/src/test/java/com/stonecode/screentimeouttile/ScreenTimeoutControllerTest.kt @@ -38,15 +38,26 @@ class ScreenTimeoutControllerTest { assertFalse(ScreenTimeoutController(readOnlyStore).canModifySystemSettings()) } + @Test(expected = IllegalStateException::class) + fun shouldExposeReadFailure_whenStoreCannotReadTimeout() { + val store = FakeScreenTimeoutSettingsStore(readFailure = true) + + ScreenTimeoutController(store).getCurrentTimeout() + } + private class FakeScreenTimeoutSettingsStore( var currentTimeoutMs: Int = 30_000, private val canWrite: Boolean = true, private val acceptsWrites: Boolean = true, + private val readFailure: Boolean = false, ) : ScreenTimeoutSettingsStore { override fun canWrite(): Boolean = canWrite - override fun getTimeoutMs(): Int = currentTimeoutMs + override fun getTimeoutMs(): Int { + check(!readFailure) { "Timeout unavailable" } + return currentTimeoutMs + } override fun setTimeoutMs(timeoutMs: Int): Boolean { if (!acceptsWrites) return false diff --git a/docs/modernization-summary.md b/docs/modernization-summary.md index 9913583..1a8013d 100644 --- a/docs/modernization-summary.md +++ b/docs/modernization-summary.md @@ -26,3 +26,12 @@ ANDROID_HOME=/Users/monroe/Library/Android/sdk ANDROID_SDK_ROOT=/Users/monroe/Library/Android/sdk ./gradlew :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:installDebug --console=plain git diff --check ``` + +## August 13, 2026 Production Hardening + +- Version `1.1.0` / code `2` uses a separate `.debug` application ID for dogfood installs. +- Missing settings values remain unavailable instead of silently becoming 30 seconds. +- OEM settings/tile-request failures and timeout write failures are surfaced without crashing. +- Permission rationale, tile state accessibility, local release-signing plumbing, CI, privacy policy, and wrapper checksum verification were added. +- Logged JVM tests, Android-test compilation, release lint, debug assembly, and unsigned release bundling passed. +- The exact debug APK was installed and cold-launched on Gina’s `SM-F766U1`; visual QA, permission approval, and tile placement remain blocked by the phone lock/human-consent gate. diff --git a/docs/release-notes-1.1.0.md b/docs/release-notes-1.1.0.md new file mode 100644 index 0000000..1a0fcdf --- /dev/null +++ b/docs/release-notes-1.1.0.md @@ -0,0 +1,10 @@ +# Screen Timeout Tile 1.1.0 + +- First signed public GitHub APK release. +- Adds an in-app legend explaining the 10-minute, 30-second, and permission icons. +- Keeps missing or unreadable timeout values visibly unavailable instead of reporting a false 30-second value. +- Explains the narrow use of Android’s Modify system settings access. +- Handles OEM failures when opening system settings or requesting tile placement without crashing. +- Shows explicit tile errors when a timeout write fails. +- Improves Quick Settings accessibility state and Android 7-9 labels. +- Adds isolated debug installs, optional local upload-key signing, CI, and Gradle wrapper checksum verification. diff --git a/docs/release-signing.md b/docs/release-signing.md new file mode 100644 index 0000000..803ea4a --- /dev/null +++ b/docs/release-signing.md @@ -0,0 +1,31 @@ +# Release Signing + +The GitHub APK signing identity is intentionally stored outside the repository. + +## Local source of truth + +- Keystore: `~/Library/Application Support/StoneHub/ScreenTimeoutTile/signing/screen-timeout-tile-release.jks` +- Alias: `screen-timeout-tile` +- Keychain service: `StoneHub Screen Timeout Tile release signing` +- Keychain account: `screen-timeout-tile` +- Certificate SHA-256: `EC:61:E0:4B:3C:48:E5:7B:25:A2:FC:A4:47:6F:D0:C7:8E:B6:73:CA:ED:E7:F4:88:04:00:8C:FC:DB:0E:F3:01` +- Certificate validity: August 18, 2026 through January 3, 2054 + +## Build a signed release + +Load the secret from macOS Keychain without printing it: + +```bash +export SCREEN_TIMEOUT_RELEASE_STORE_FILE="$HOME/Library/Application Support/StoneHub/ScreenTimeoutTile/signing/screen-timeout-tile-release.jks" +export SCREEN_TIMEOUT_RELEASE_KEY_ALIAS="screen-timeout-tile" +export SCREEN_TIMEOUT_RELEASE_STORE_PASSWORD="$(security find-generic-password -s 'StoneHub Screen Timeout Tile release signing' -a 'screen-timeout-tile' -w)" +export SCREEN_TIMEOUT_RELEASE_KEY_PASSWORD="$SCREEN_TIMEOUT_RELEASE_STORE_PASSWORD" +./gradlew :app:assembleRelease +unset SCREEN_TIMEOUT_RELEASE_STORE_FILE SCREEN_TIMEOUT_RELEASE_KEY_ALIAS SCREEN_TIMEOUT_RELEASE_STORE_PASSWORD SCREEN_TIMEOUT_RELEASE_KEY_PASSWORD +``` + +Verify the resulting APK with Android SDK `apksigner` before publishing it. + +## Recovery requirement + +Back up both the `.jks` keystore and its Keychain password in a secure location. GitHub-distributed updates must keep using this signing key. Never commit the keystore, passwords, or `release-signing.properties`. diff --git a/docs/release-todo.md b/docs/release-todo.md index 1b07993..b895232 100644 --- a/docs/release-todo.md +++ b/docs/release-todo.md @@ -24,18 +24,22 @@ ## Code / Release Engineering To Do -- Run `./gradlew :app:lintRelease`. -- Run `./gradlew :app:bundleRelease`. -- Verify the generated release artifact. -- Add a signing setup that reads upload-key values from local, untracked Gradle properties. -- Bump `versionCode` and `versionName` before every Play upload. -- Add release notes for the first public build. +- [x] Run `./gradlew :app:lintRelease` for version 1.1.0 (zero errors on August 13, 2026). +- [x] Run `./gradlew :app:bundleRelease` for version 1.1.0. +- [x] Verify the generated release metadata and confirm the bundle remains unsigned without owner credentials. +- [x] Add signing setup that reads upload-key values from local, untracked Gradle properties. +- [x] Bump to `versionCode=2` and `versionName=1.1.0`. +- [x] Add release notes for 1.1.0. +- [x] Create the permanent GitHub APK signing key and store its password in macOS Keychain. +- [ ] Back up the GitHub APK signing keystore and Keychain password in a second secure location. +- [x] Build and verify the production-signed APK and AAB for version 1.1.0. +- [ ] Publish GitHub Release `v1.1.0` with the signed APK and checksums. - Run manual QA on at least: - Samsung Android 16 phone - Android 13+ device for direct tile placement - Android 7-12 device or emulator for manual tile-placement fallback - Confirm `WRITE_SETTINGS` behavior survives reboot and tile refresh. -- Confirm the app collects no data and has no accidental network permission. +- [x] Confirm the merged debug manifest has no internet permission or data collection SDK. ## Nice To Have Before Public Launch diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7f481ec..ad26fdb 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -2,5 +2,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionSha256Sum=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/release-signing.properties.example b/release-signing.properties.example new file mode 100644 index 0000000..683f194 --- /dev/null +++ b/release-signing.properties.example @@ -0,0 +1,6 @@ +# Copy this file to release-signing.properties and keep the real values local. +# The store path is resolved from the repository root. +storeFile=keystores/upload-key.jks +storePassword=replace-me +keyAlias=upload +keyPassword=replace-me