From b30d9681a063ed4d834604611f0c03c6a586d841 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 19:58:58 +0000 Subject: [PATCH 01/21] Add binary clock Glance widget module Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/f2b62f11-eba1-4569-ada4-298ba940be3b Co-authored-by: anod <171704+anod@users.noreply.github.com> --- README.md | 14 +++ binaryclock/build.gradle.kts | 24 ++++ .../src/androidMain/AndroidManifest.xml | 16 +++ .../binaryclock/BinaryClockGlanceWidget.kt | 109 ++++++++++++++++++ .../src/androidMain/res/values/strings.xml | 4 + .../res/xml/binary_clock_widget_info.xml | 12 ++ .../binaryclock/BinaryClockDigits.kt | 25 ++++ .../binaryclock/BinaryClockDigitsTest.kt | 33 ++++++ 8 files changed, 237 insertions(+) create mode 100644 binaryclock/build.gradle.kts create mode 100644 binaryclock/src/androidMain/AndroidManifest.xml create mode 100644 binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt create mode 100644 binaryclock/src/androidMain/res/values/strings.xml create mode 100644 binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml create mode 100644 binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt create mode 100644 binaryclock/src/commonTest/kotlin/info/anodsplace/binaryclock/BinaryClockDigitsTest.kt diff --git a/README.md b/README.md index 8228592..4b3a36f 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,17 @@ Android standard framework extensions ## Images - Images MemoryCache - ImageLoader using MemoryCache + +## Binary Clock Glance Widget + +The `binaryclock` module provides a minimal Android home screen widget built with Jetpack Glance. It exposes `BinaryClockGlanceWidget` through `BinaryClockWidgetReceiver`, so an Android app can include the module and merge the receiver metadata into its manifest. + +The widget renders the current 24-hour time as six columns for `HHMMSS`. Each decimal digit is shown as four vertical bits with values `8`, `4`, `2`, and `1` from top to bottom. Bright dots are active bits and muted dots are inactive bits, using a dark monochrome style. + +Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget supports seconds in the display but should not be treated as a guaranteed per-second clock surface. The metadata uses Android's periodic app widget update mechanism at the platform minimum cadence. + +Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: + +```sh +./gradlew :lib:binaryclock:check +``` diff --git a/binaryclock/build.gradle.kts b/binaryclock/build.gradle.kts new file mode 100644 index 0000000..17d1f1d --- /dev/null +++ b/binaryclock/build.gradle.kts @@ -0,0 +1,24 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.multiplatform.android.library) +} + +kotlin { + androidLibrary { + namespace = "info.anodsplace.binaryclock" + compileSdk = 36 + minSdk = 31 + androidResources { + enable = true + } + } + + sourceSets { + commonTest.dependencies { + implementation(kotlin("test")) + } + androidMain.dependencies { + implementation("androidx.glance:glance-appwidget:1.1.1") + } + } +} diff --git a/binaryclock/src/androidMain/AndroidManifest.xml b/binaryclock/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..5158e52 --- /dev/null +++ b/binaryclock/src/androidMain/AndroidManifest.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt new file mode 100644 index 0000000..ccb1569 --- /dev/null +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -0,0 +1,109 @@ +package info.anodsplace.binaryclock + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.LocalSize +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.height +import androidx.glance.layout.padding +import androidx.glance.layout.width +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider +import java.time.LocalTime + +class BinaryClockGlanceWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { + val now = LocalTime.now() + BinaryClockWidgetContent( + digits = BinaryClockDigits.timeDigits( + hour = now.hour, + minute = now.minute, + second = now.second, + ), + ) + } + } +} + +class BinaryClockWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = BinaryClockGlanceWidget() +} + +@Composable +internal fun BinaryClockWidgetContent(digits: List) { + val size = LocalSize.current + Column( + modifier = GlanceModifier + .fillMaxSize() + .background(ColorProvider(Color(0xFF101010))) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + digits.forEachIndexed { index, digit -> + BinaryDigitColumn( + digit = digit, + label = labels[index], + compact = size.width < 180.dp, + ) + if (index < digits.lastIndex) { + Spacer(modifier = GlanceModifier.width(if (index == 1 || index == 3) 12.dp else 6.dp)) + } + } + } + } +} + +@Composable +private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { + val dotSize = if (compact) 16.dp else 20.dp + val dotFontSize = if (compact) 14.sp else 18.sp + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalAlignment = Alignment.CenterVertically, + ) { + BinaryClockDigits.digitBits(digit).forEach { active -> + Text( + text = if (active) "●" else "○", + modifier = GlanceModifier.width(dotSize).height(dotSize), + style = TextStyle( + color = ColorProvider(if (active) Color.White else Color(0xFF555555)), + fontSize = dotFontSize, + textAlign = TextAlign.Center, + ), + ) + } + Spacer(modifier = GlanceModifier.height(4.dp)) + Text( + text = label, + style = TextStyle( + color = ColorProvider(Color(0xFF888888)), + fontSize = 10.sp, + textAlign = TextAlign.Center, + ), + ) + } +} + +private val labels = listOf("H", "H", "M", "M", "S", "S") diff --git a/binaryclock/src/androidMain/res/values/strings.xml b/binaryclock/src/androidMain/res/values/strings.xml new file mode 100644 index 0000000..a073ffe --- /dev/null +++ b/binaryclock/src/androidMain/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Binary clock widget + diff --git a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml new file mode 100644 index 0000000..19efede --- /dev/null +++ b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml @@ -0,0 +1,12 @@ + + diff --git a/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt b/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt new file mode 100644 index 0000000..8e49fb4 --- /dev/null +++ b/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt @@ -0,0 +1,25 @@ +package info.anodsplace.binaryclock + +object BinaryClockDigits { + val bitValues = listOf(8, 4, 2, 1) + + fun digitBits(digit: Int): List { + require(digit in 0..9) { "Digit must be between 0 and 9" } + return bitValues.map { bit -> digit and bit == bit } + } + + fun timeDigits(hour: Int, minute: Int, second: Int): List { + require(hour in 0..23) { "Hour must be between 0 and 23" } + require(minute in 0..59) { "Minute must be between 0 and 59" } + require(second in 0..59) { "Second must be between 0 and 59" } + + return listOf( + hour / 10, + hour % 10, + minute / 10, + minute % 10, + second / 10, + second % 10, + ) + } +} diff --git a/binaryclock/src/commonTest/kotlin/info/anodsplace/binaryclock/BinaryClockDigitsTest.kt b/binaryclock/src/commonTest/kotlin/info/anodsplace/binaryclock/BinaryClockDigitsTest.kt new file mode 100644 index 0000000..6884733 --- /dev/null +++ b/binaryclock/src/commonTest/kotlin/info/anodsplace/binaryclock/BinaryClockDigitsTest.kt @@ -0,0 +1,33 @@ +package info.anodsplace.binaryclock + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class BinaryClockDigitsTest { + + @Test + fun digitBitsMapDecimalDigitToFourBits() { + assertEquals(listOf(false, false, false, false), BinaryClockDigits.digitBits(0)) + assertEquals(listOf(false, false, false, true), BinaryClockDigits.digitBits(1)) + assertEquals(listOf(false, false, true, false), BinaryClockDigits.digitBits(2)) + assertEquals(listOf(false, true, false, true), BinaryClockDigits.digitBits(5)) + assertEquals(listOf(true, false, false, true), BinaryClockDigits.digitBits(9)) + } + + @Test + fun timeDigitsAlwaysReturnSixDigits() { + assertEquals(listOf(0, 0, 0, 0, 0, 0), BinaryClockDigits.timeDigits(0, 0, 0)) + assertEquals(listOf(1, 2, 0, 0, 0, 0), BinaryClockDigits.timeDigits(12, 0, 0)) + assertEquals(listOf(0, 7, 0, 5, 0, 9), BinaryClockDigits.timeDigits(7, 5, 9)) + assertEquals(listOf(2, 3, 5, 9, 5, 9), BinaryClockDigits.timeDigits(23, 59, 59)) + } + + @Test + fun invalidValuesFailFast() { + assertFailsWith { BinaryClockDigits.digitBits(10) } + assertFailsWith { BinaryClockDigits.timeDigits(24, 0, 0) } + assertFailsWith { BinaryClockDigits.timeDigits(0, 60, 0) } + assertFailsWith { BinaryClockDigits.timeDigits(0, 0, 60) } + } +} From 177c825ed5b4b73bc4f6dfaf310ac1cc20b06dc4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 19:59:56 +0000 Subject: [PATCH 02/21] Clarify widget update cadence Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/f2b62f11-eba1-4569-ada4-298ba940be3b Co-authored-by: anod <171704+anod@users.noreply.github.com> --- binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml index 19efede..0164bb7 100644 --- a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml +++ b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml @@ -1,4 +1,5 @@ + Date: Sat, 2 May 2026 20:00:32 +0000 Subject: [PATCH 03/21] Harden binary clock syntax compatibility Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/f2b62f11-eba1-4569-ada4-298ba940be3b Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 3 --- .../kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index ccb1569..5fa4976 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -21,7 +21,6 @@ import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.text.Text -import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import java.time.LocalTime @@ -90,7 +89,6 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { style = TextStyle( color = ColorProvider(if (active) Color.White else Color(0xFF555555)), fontSize = dotFontSize, - textAlign = TextAlign.Center, ), ) } @@ -100,7 +98,6 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { style = TextStyle( color = ColorProvider(Color(0xFF888888)), fontSize = 10.sp, - textAlign = TextAlign.Center, ), ) } diff --git a/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt b/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt index 8e49fb4..7136629 100644 --- a/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt +++ b/binaryclock/src/commonMain/kotlin/info/anodsplace/binaryclock/BinaryClockDigits.kt @@ -5,7 +5,7 @@ object BinaryClockDigits { fun digitBits(digit: Int): List { require(digit in 0..9) { "Digit must be between 0 and 9" } - return bitValues.map { bit -> digit and bit == bit } + return bitValues.map { bit -> (digit and bit) == bit } } fun timeDigits(hour: Int, minute: Int, second: Int): List { From 59e119c3eef93f10546caaae76aab1eba6ef8714 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:01:11 +0000 Subject: [PATCH 04/21] Clarify app widget update minimum Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/f2b62f11-eba1-4569-ada4-298ba940be3b Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../src/androidMain/res/xml/binary_clock_widget_info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml index 0164bb7..9062d18 100644 --- a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml +++ b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml @@ -1,5 +1,5 @@ - + Date: Sat, 2 May 2026 20:01:44 +0000 Subject: [PATCH 05/21] Clarify compact widget mode Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/f2b62f11-eba1-4569-ada4-298ba940be3b Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index 5fa4976..9ecb332 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -46,7 +46,7 @@ class BinaryClockWidgetReceiver : GlanceAppWidgetReceiver() { @Composable internal fun BinaryClockWidgetContent(digits: List) { - val size = LocalSize.current + val isCompactMode = LocalSize.current.width < 180.dp Column( modifier = GlanceModifier .fillMaxSize() @@ -63,7 +63,7 @@ internal fun BinaryClockWidgetContent(digits: List) { BinaryDigitColumn( digit = digit, label = labels[index], - compact = size.width < 180.dp, + compact = isCompactMode, ) if (index < digits.lastIndex) { Spacer(modifier = GlanceModifier.width(if (index == 1 || index == 3) 12.dp else 6.dp)) From 17e2a0ea3d8c214145aac412d571256d0f643681 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:05:17 +0000 Subject: [PATCH 06/21] Add APK build publish workflow Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/ad899af4-ada9-4314-88b2-5d7be065470a Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 157 ++++++++++++++++++++++++ README.md | 15 +++ 2 files changed, 172 insertions(+) create mode 100644 .github/workflows/build-publish-apk.yml diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml new file mode 100644 index 0000000..1e1d578 --- /dev/null +++ b/.github/workflows/build-publish-apk.yml @@ -0,0 +1,157 @@ +name: Build and publish APK + +on: + workflow_dispatch: + inputs: + module: + description: Android application module path, without the assemble task name + required: true + default: app + publish_release: + description: Attach the APK to a GitHub Release + required: true + type: boolean + default: false + release_tag: + description: Release tag to publish to when publish_release is enabled; defaults to apk- + required: false + default: '' + push: + tags: + - 'v*' + +permissions: + contents: write + +concurrency: + group: build-publish-apk-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build signed release APK + runs-on: ubuntu-latest + env: + APP_MODULE: ${{ github.event.inputs.module || 'app' }} + PUBLISH_RELEASE: ${{ github.event.inputs.publish_release || 'false' }} + REQUESTED_RELEASE_TAG: ${{ github.event.inputs.release_tag || '' }} + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set up JDK + uses: actions/setup-java@v4.7.1 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4.4.2 + + - name: Validate Android signing secrets + env: + ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} + ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + run: | + set -euo pipefail + test -n "$ANDROID_SIGNING_KEYSTORE_BASE64" + test -n "$ANDROID_SIGNING_KEYSTORE_PASSWORD" + test -n "$ANDROID_SIGNING_KEY_ALIAS" + test -n "$ANDROID_SIGNING_KEY_PASSWORD" + + - name: Prepare build metadata + run: | + set -euo pipefail + if [ ! -x ./gradlew ]; then + echo "This workflow requires a Gradle wrapper at the repository root." >&2 + exit 1 + fi + + module_path="${APP_MODULE#:}" + module_dir="${module_path//://}" + if [ ! -d "$module_dir" ]; then + echo "Android application module '$APP_MODULE' was not found at '$module_dir'." >&2 + exit 1 + fi + + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + version_name="${GITHUB_REF_NAME#v}" + else + version_name="0.1.${GITHUB_RUN_NUMBER}" + fi + + { + echo "MODULE_PATH=$module_path" + echo "MODULE_DIR=$module_dir" + echo "VERSION_CODE=${GITHUB_RUN_NUMBER}" + echo "VERSION_NAME=$version_name" + echo "APK_NAME=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" + } >> "$GITHUB_ENV" + + - name: Decode signing keystore + env: + ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} + run: | + set -euo pipefail + keystore_file="$RUNNER_TEMP/android-release.keystore" + printf '%s' "$ANDROID_SIGNING_KEYSTORE_BASE64" | base64 --decode > "$keystore_file" + echo "KEYSTORE_FILE=$keystore_file" >> "$GITHUB_ENV" + + - name: Build signed release APK + env: + ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + run: | + set -euo pipefail + ./gradlew ":${MODULE_PATH}:assembleRelease" \ + --no-daemon \ + -Pandroid.injected.version.code="$VERSION_CODE" \ + -Pandroid.injected.version.name="$VERSION_NAME" \ + -Pandroid.injected.signing.store.file="$KEYSTORE_FILE" \ + -Pandroid.injected.signing.store.password="$ANDROID_SIGNING_KEYSTORE_PASSWORD" \ + -Pandroid.injected.signing.key.alias="$ANDROID_SIGNING_KEY_ALIAS" \ + -Pandroid.injected.signing.key.password="$ANDROID_SIGNING_KEY_PASSWORD" + + - name: Locate signed APK + run: | + set -euo pipefail + apk_path="$(find "$MODULE_DIR/build/outputs/apk/release" -type f -name '*.apk' ! -name '*unsigned*' | sort | head -n 1)" + if [ -z "$apk_path" ]; then + echo "No signed release APK found in $MODULE_DIR/build/outputs/apk/release." >&2 + exit 1 + fi + published_apk="$RUNNER_TEMP/$APK_NAME" + cp "$apk_path" "$published_apk" + echo "APK_PATH=$published_apk" >> "$GITHUB_ENV" + + - name: Upload APK artifact + uses: actions/upload-artifact@v4.6.2 + with: + name: ${{ env.APK_NAME }} + path: ${{ env.APK_PATH }} + if-no-files-found: error + + - name: Publish APK to GitHub Release + if: github.ref_type == 'tag' || github.event.inputs.publish_release == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + if [ -n "$REQUESTED_RELEASE_TAG" ]; then + release_tag="$REQUESTED_RELEASE_TAG" + elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then + release_tag="$GITHUB_REF_NAME" + else + release_tag="apk-${VERSION_CODE}" + fi + + if ! gh release view "$release_tag" >/dev/null 2>&1; then + gh release create "$release_tag" \ + --title "$release_tag" \ + --notes "Signed APK built from ${GITHUB_SHA} with versionCode ${VERSION_CODE} and versionName ${VERSION_NAME}." + fi + + gh release upload "$release_tag" "$APK_PATH" --clobber diff --git a/README.md b/README.md index 4b3a36f..81b759b 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,18 @@ Build and test this module from the consuming Gradle project that includes this ```sh ./gradlew :lib:binaryclock:check ``` + +## APK build and publish workflow + +`.github/workflows/build-publish-apk.yml` builds a signed release APK for an Android application module and uploads it as a workflow artifact. When the workflow runs for a `v*` tag, or when `publish_release` is enabled for a manual run, it also uploads the APK to a GitHub Release. + +The workflow reuses the same signing key between builds through repository secrets. Generate the release keystore once, base64-encode it, and store these secrets in GitHub: + +- `ANDROID_SIGNING_KEYSTORE_BASE64` +- `ANDROID_SIGNING_KEYSTORE_PASSWORD` +- `ANDROID_SIGNING_KEY_ALIAS` +- `ANDROID_SIGNING_KEY_PASSWORD` + +The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use the tag name without a leading `v` as the version name; manual non-tag builds use `0.1.`. + +Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so a consuming project or future app module must provide a root Gradle wrapper and application module for the APK build to succeed. From a2774cfce9187379f47e0c43c8ed00c0f7393c98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:09:49 +0000 Subject: [PATCH 07/21] Run APK workflow on pull requests Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/40be9336-0196-42b5-91d7-d15c1df278f3 Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 61 +++++++++++++++++++++---- README.md | 2 +- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 1e1d578..c852cdb 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -1,6 +1,7 @@ name: Build and publish APK on: + pull_request: workflow_dispatch: inputs: module: @@ -21,7 +22,7 @@ on: - 'v*' permissions: - contents: write + contents: read concurrency: group: build-publish-apk-${{ github.ref }} @@ -29,12 +30,14 @@ concurrency: jobs: build: - name: Build signed release APK + name: Build release APK runs-on: ubuntu-latest + outputs: + apk_name: ${{ steps.metadata.outputs.apk_name }} + version_code: ${{ steps.metadata.outputs.version_code }} + version_name: ${{ steps.metadata.outputs.version_name }} env: APP_MODULE: ${{ github.event.inputs.module || 'app' }} - PUBLISH_RELEASE: ${{ github.event.inputs.publish_release || 'false' }} - REQUESTED_RELEASE_TAG: ${{ github.event.inputs.release_tag || '' }} steps: - name: Checkout uses: actions/checkout@v4.2.2 @@ -49,6 +52,7 @@ jobs: uses: gradle/actions/setup-gradle@v4.4.2 - name: Validate Android signing secrets + if: github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} @@ -62,6 +66,7 @@ jobs: test -n "$ANDROID_SIGNING_KEY_PASSWORD" - name: Prepare build metadata + id: metadata run: | set -euo pipefail if [ ! -x ./gradlew ]; then @@ -89,8 +94,14 @@ jobs: echo "VERSION_NAME=$version_name" echo "APK_NAME=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" } >> "$GITHUB_ENV" + { + echo "apk_name=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" + echo "version_code=${GITHUB_RUN_NUMBER}" + echo "version_name=$version_name" + } >> "$GITHUB_OUTPUT" - name: Decode signing keystore + if: github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} run: | @@ -99,7 +110,17 @@ jobs: printf '%s' "$ANDROID_SIGNING_KEYSTORE_BASE64" | base64 --decode > "$keystore_file" echo "KEYSTORE_FILE=$keystore_file" >> "$GITHUB_ENV" + - name: Build unsigned release APK + if: github.event_name == 'pull_request' + run: | + set -euo pipefail + ./gradlew ":${MODULE_PATH}:assembleRelease" \ + --no-daemon \ + -Pandroid.injected.version.code="$VERSION_CODE" \ + -Pandroid.injected.version.name="$VERSION_NAME" + - name: Build signed release APK + if: github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} @@ -115,12 +136,16 @@ jobs: -Pandroid.injected.signing.key.alias="$ANDROID_SIGNING_KEY_ALIAS" \ -Pandroid.injected.signing.key.password="$ANDROID_SIGNING_KEY_PASSWORD" - - name: Locate signed APK + - name: Locate APK run: | set -euo pipefail - apk_path="$(find "$MODULE_DIR/build/outputs/apk/release" -type f -name '*.apk' ! -name '*unsigned*' | sort | head -n 1)" + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + apk_path="$(find "$MODULE_DIR/build/outputs/apk/release" -type f -name '*.apk' | sort | head -n 1)" + else + apk_path="$(find "$MODULE_DIR/build/outputs/apk/release" -type f -name '*.apk' ! -name '*unsigned*' | sort | head -n 1)" + fi if [ -z "$apk_path" ]; then - echo "No signed release APK found in $MODULE_DIR/build/outputs/apk/release." >&2 + echo "No release APK found in $MODULE_DIR/build/outputs/apk/release." >&2 exit 1 fi published_apk="$RUNNER_TEMP/$APK_NAME" @@ -134,8 +159,26 @@ jobs: path: ${{ env.APK_PATH }} if-no-files-found: error + publish: + name: Publish APK to GitHub Release + runs-on: ubuntu-latest + needs: build + if: github.ref_type == 'tag' || github.event.inputs.publish_release == 'true' + permissions: + contents: write + env: + REQUESTED_RELEASE_TAG: ${{ github.event.inputs.release_tag || '' }} + VERSION_CODE: ${{ needs.build.outputs.version_code }} + VERSION_NAME: ${{ needs.build.outputs.version_name }} + APK_NAME: ${{ needs.build.outputs.apk_name }} + steps: + - name: Download APK artifact + uses: actions/download-artifact@v4.3.0 + with: + name: ${{ env.APK_NAME }} + path: ${{ runner.temp }} + - name: Publish APK to GitHub Release - if: github.ref_type == 'tag' || github.event.inputs.publish_release == 'true' env: GH_TOKEN: ${{ github.token }} run: | @@ -154,4 +197,4 @@ jobs: --notes "Signed APK built from ${GITHUB_SHA} with versionCode ${VERSION_CODE} and versionName ${VERSION_NAME}." fi - gh release upload "$release_tag" "$APK_PATH" --clobber + gh release upload "$release_tag" "$RUNNER_TEMP/$APK_NAME" --clobber diff --git a/README.md b/README.md index 81b759b..bbccce4 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Build and test this module from the consuming Gradle project that includes this ## APK build and publish workflow -`.github/workflows/build-publish-apk.yml` builds a signed release APK for an Android application module and uploads it as a workflow artifact. When the workflow runs for a `v*` tag, or when `publish_release` is enabled for a manual run, it also uploads the APK to a GitHub Release. +`.github/workflows/build-publish-apk.yml` builds a release APK for an Android application module on pull requests, manual runs, and `v*` tags, then uploads it as a workflow artifact. Pull request builds do not use signing secrets or publish releases. When the workflow runs for a `v*` tag, or when `publish_release` is enabled for a manual run, it signs the APK and uploads it to a GitHub Release. The workflow reuses the same signing key between builds through repository secrets. Generate the release keystore once, base64-encode it, and store these secrets in GitHub: From 2b7ce05d608eec23c342ae4bb086205f7db4525b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:11:20 +0000 Subject: [PATCH 08/21] Skip PR APK build when app project missing Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/40be9336-0196-42b5-91d7-d15c1df278f3 Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 40 ++++++++++++++++++++----- README.md | 2 +- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index c852cdb..4e9448d 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -34,6 +34,7 @@ jobs: runs-on: ubuntu-latest outputs: apk_name: ${{ steps.metadata.outputs.apk_name }} + build_apk: ${{ steps.metadata.outputs.build_apk }} version_code: ${{ steps.metadata.outputs.version_code }} version_name: ${{ steps.metadata.outputs.version_name }} env: @@ -70,15 +71,29 @@ jobs: run: | set -euo pipefail if [ ! -x ./gradlew ]; then - echo "This workflow requires a Gradle wrapper at the repository root." >&2 - exit 1 + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "No Gradle wrapper found at the repository root; skipping APK build for this pull request." + echo "BUILD_APK=false" >> "$GITHUB_ENV" + echo "build_apk=false" >> "$GITHUB_OUTPUT" + exit 0 + else + echo "This workflow requires a Gradle wrapper at the repository root." >&2 + exit 1 + fi fi module_path="${APP_MODULE#:}" module_dir="${module_path//://}" if [ ! -d "$module_dir" ]; then - echo "Android application module '$APP_MODULE' was not found at '$module_dir'." >&2 - exit 1 + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + echo "Android application module '$APP_MODULE' was not found at '$module_dir'; skipping APK build for this pull request." + echo "BUILD_APK=false" >> "$GITHUB_ENV" + echo "build_apk=false" >> "$GITHUB_OUTPUT" + exit 0 + else + echo "Android application module '$APP_MODULE' was not found at '$module_dir'." >&2 + exit 1 + fi fi if [ "${GITHUB_REF_TYPE}" = "tag" ]; then @@ -90,18 +105,25 @@ jobs: { echo "MODULE_PATH=$module_path" echo "MODULE_DIR=$module_dir" + echo "BUILD_APK=true" echo "VERSION_CODE=${GITHUB_RUN_NUMBER}" echo "VERSION_NAME=$version_name" echo "APK_NAME=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" } >> "$GITHUB_ENV" { echo "apk_name=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" + echo "build_apk=true" echo "version_code=${GITHUB_RUN_NUMBER}" echo "version_name=$version_name" } >> "$GITHUB_OUTPUT" + - name: Report skipped APK build + if: env.BUILD_APK != 'true' + run: | + echo "No APK build was run because this repository checkout does not contain a root Gradle application project." >> "$GITHUB_STEP_SUMMARY" + - name: Decode signing keystore - if: github.event_name != 'pull_request' + if: env.BUILD_APK == 'true' && github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_BASE64: ${{ secrets.ANDROID_SIGNING_KEYSTORE_BASE64 }} run: | @@ -111,7 +133,7 @@ jobs: echo "KEYSTORE_FILE=$keystore_file" >> "$GITHUB_ENV" - name: Build unsigned release APK - if: github.event_name == 'pull_request' + if: env.BUILD_APK == 'true' && github.event_name == 'pull_request' run: | set -euo pipefail ./gradlew ":${MODULE_PATH}:assembleRelease" \ @@ -120,7 +142,7 @@ jobs: -Pandroid.injected.version.name="$VERSION_NAME" - name: Build signed release APK - if: github.event_name != 'pull_request' + if: env.BUILD_APK == 'true' && github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} @@ -137,6 +159,7 @@ jobs: -Pandroid.injected.signing.key.password="$ANDROID_SIGNING_KEY_PASSWORD" - name: Locate APK + if: env.BUILD_APK == 'true' run: | set -euo pipefail if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then @@ -153,6 +176,7 @@ jobs: echo "APK_PATH=$published_apk" >> "$GITHUB_ENV" - name: Upload APK artifact + if: env.BUILD_APK == 'true' uses: actions/upload-artifact@v4.6.2 with: name: ${{ env.APK_NAME }} @@ -163,7 +187,7 @@ jobs: name: Publish APK to GitHub Release runs-on: ubuntu-latest needs: build - if: github.ref_type == 'tag' || github.event.inputs.publish_release == 'true' + if: needs.build.outputs.build_apk == 'true' && (github.ref_type == 'tag' || github.event.inputs.publish_release == 'true') permissions: contents: write env: diff --git a/README.md b/README.md index bbccce4..cb87e7d 100644 --- a/README.md +++ b/README.md @@ -42,4 +42,4 @@ The workflow reuses the same signing key between builds through repository secre The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use the tag name without a leading `v` as the version name; manual non-tag builds use `0.1.`. -Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so a consuming project or future app module must provide a root Gradle wrapper and application module for the APK build to succeed. +Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual and tag release runs require a root Gradle wrapper and application module for the APK build to succeed. From 04ad4dd024650f0e2959505372ce959985a8fe70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:11:59 +0000 Subject: [PATCH 09/21] Improve release lookup logging Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/40be9336-0196-42b5-91d7-d15c1df278f3 Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 4e9448d..b453738 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -215,7 +215,9 @@ jobs: release_tag="apk-${VERSION_CODE}" fi - if ! gh release view "$release_tag" >/dev/null 2>&1; then + if gh release view "$release_tag" >/dev/null; then + echo "Uploading APK to existing release $release_tag." + else gh release create "$release_tag" \ --title "$release_tag" \ --notes "Signed APK built from ${GITHUB_SHA} with versionCode ${VERSION_CODE} and versionName ${VERSION_NAME}." From 6c9644a97936be66d9ba3d3d44d012998255d62d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:12:41 +0000 Subject: [PATCH 10/21] Document release existence check Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/40be9336-0196-42b5-91d7-d15c1df278f3 Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index b453738..e1a8fed 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -215,7 +215,8 @@ jobs: release_tag="apk-${VERSION_CODE}" fi - if gh release view "$release_tag" >/dev/null; then + # Suppress the expected "not found" error when the release has not been created yet. + if gh release view "$release_tag" >/dev/null 2>&1; then echo "Uploading APK to existing release $release_tag." else gh release create "$release_tag" \ From 953cf3aea1c6e1f3191d50aa856c1494892bce66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:13:25 +0000 Subject: [PATCH 11/21] Validate release tag version format Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/40be9336-0196-42b5-91d7-d15c1df278f3 Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 7 +++++++ README.md | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index e1a8fed..2952a69 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -98,6 +98,13 @@ jobs: if [ "${GITHUB_REF_TYPE}" = "tag" ]; then version_name="${GITHUB_REF_NAME#v}" + case "$version_name" in + [0-9]*) ;; + *) + echo "Tag builds must use a version tag in the form v, for example v1.0.0." >&2 + exit 1 + ;; + esac else version_name="0.1.${GITHUB_RUN_NUMBER}" fi diff --git a/README.md b/README.md index cb87e7d..3bdc907 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,6 @@ The workflow reuses the same signing key between builds through repository secre - `ANDROID_SIGNING_KEY_ALIAS` - `ANDROID_SIGNING_KEY_PASSWORD` -The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use the tag name without a leading `v` as the version name; manual non-tag builds use `0.1.`. +The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use `v` tags, such as `v1.0.0`, and use the tag name without the leading `v` as the version name; manual non-tag builds use `0.1.`. Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual and tag release runs require a root Gradle wrapper and application module for the APK build to succeed. From 923301683f980dc05e08f0426f57274f739eab70 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:20:56 +0000 Subject: [PATCH 12/21] Resolve PR review comments Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 5 +- README.md | 8 +-- binaryclock/build.gradle.kts | 4 +- .../binaryclock/BinaryClockGlanceWidget.kt | 65 ++++++++++++++++++- .../res/xml/binary_clock_widget_info.xml | 4 +- 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 2952a69..4e7f791 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -17,9 +17,6 @@ on: description: Release tag to publish to when publish_release is enabled; defaults to apk- required: false default: '' - push: - tags: - - 'v*' permissions: contents: read @@ -83,7 +80,7 @@ jobs: fi module_path="${APP_MODULE#:}" - module_dir="${module_path//://}" + module_dir="${module_path//:/\/}" if [ ! -d "$module_dir" ]; then if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then echo "Android application module '$APP_MODULE' was not found at '$module_dir'; skipping APK build for this pull request." diff --git a/README.md b/README.md index 3bdc907..5475012 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The `binaryclock` module provides a minimal Android home screen widget built wit The widget renders the current 24-hour time as six columns for `HHMMSS`. Each decimal digit is shown as four vertical bits with values `8`, `4`, `2`, and `1` from top to bottom. Bright dots are active bits and muted dots are inactive bits, using a dark monochrome style. -Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget supports seconds in the display but should not be treated as a guaranteed per-second clock surface. The metadata uses Android's periodic app widget update mechanism at the platform minimum cadence. +Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: @@ -31,7 +31,7 @@ Build and test this module from the consuming Gradle project that includes this ## APK build and publish workflow -`.github/workflows/build-publish-apk.yml` builds a release APK for an Android application module on pull requests, manual runs, and `v*` tags, then uploads it as a workflow artifact. Pull request builds do not use signing secrets or publish releases. When the workflow runs for a `v*` tag, or when `publish_release` is enabled for a manual run, it signs the APK and uploads it to a GitHub Release. +`.github/workflows/build-publish-apk.yml` builds a release APK for an Android application module on pull requests and manual runs, then uploads it as a workflow artifact. Pull request builds do not use signing secrets or publish releases. When `publish_release` is enabled for a manual run, it signs the APK and uploads it to a GitHub Release. The workflow reuses the same signing key between builds through repository secrets. Generate the release keystore once, base64-encode it, and store these secrets in GitHub: @@ -40,6 +40,6 @@ The workflow reuses the same signing key between builds through repository secre - `ANDROID_SIGNING_KEY_ALIAS` - `ANDROID_SIGNING_KEY_PASSWORD` -The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use `v` tags, such as `v1.0.0`, and use the tag name without the leading `v` as the version name; manual non-tag builds use `0.1.`. +The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Manual runs started from `v` tags, such as `v1.0.0`, use the tag name without the leading `v` as the version name; manual non-tag builds use `0.1.`. -Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual and tag release runs require a root Gradle wrapper and application module for the APK build to succeed. +Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual release runs require a root Gradle wrapper and application module for the APK build to succeed. diff --git a/binaryclock/build.gradle.kts b/binaryclock/build.gradle.kts index 17d1f1d..b67e682 100644 --- a/binaryclock/build.gradle.kts +++ b/binaryclock/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlin.multiplatform.android.library) + alias(libs.plugins.compose.compiler) } kotlin { @@ -18,7 +19,8 @@ kotlin { implementation(kotlin("test")) } androidMain.dependencies { - implementation("androidx.glance:glance-appwidget:1.1.1") + implementation(libs.androidx.glance.appwidget) + implementation(libs.coroutines.core) } } } diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index 9ecb332..81e74c2 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -1,6 +1,9 @@ package info.anodsplace.binaryclock +import android.app.AlarmManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -11,6 +14,7 @@ import androidx.glance.LocalSize import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import androidx.glance.appwidget.provideContent +import androidx.glance.appwidget.updateAll import androidx.glance.background import androidx.glance.layout.Alignment import androidx.glance.layout.Column @@ -24,6 +28,9 @@ import androidx.glance.text.Text import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import java.time.LocalTime +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class BinaryClockGlanceWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { @@ -42,11 +49,39 @@ class BinaryClockGlanceWidget : GlanceAppWidget() { class BinaryClockWidgetReceiver : GlanceAppWidgetReceiver() { override val glanceAppWidget: GlanceAppWidget = BinaryClockGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + BinaryClockRefreshScheduler.scheduleNext(context) + } + + override fun onDisabled(context: Context) { + BinaryClockRefreshScheduler.cancel(context) + super.onDisabled(context) + } + + override fun onReceive(context: Context, intent: Intent) { + super.onReceive(context, intent) + if (intent.action != ACTION_REFRESH_BINARY_CLOCK) { + return + } + + val pendingResult = goAsync() + CoroutineScope(Dispatchers.Default).launch { + try { + val applicationContext = context.applicationContext + glanceAppWidget.updateAll(applicationContext) + BinaryClockRefreshScheduler.scheduleNext(applicationContext) + } finally { + pendingResult.finish() + } + } + } } @Composable internal fun BinaryClockWidgetContent(digits: List) { - val isCompactMode = LocalSize.current.width < 180.dp + val isCompactMode = LocalSize.current.width <= 180.dp Column( modifier = GlanceModifier .fillMaxSize() @@ -104,3 +139,31 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { } private val labels = listOf("H", "H", "M", "M", "S", "S") + +private const val ACTION_REFRESH_BINARY_CLOCK = "info.anodsplace.binaryclock.action.REFRESH" +private const val MINUTE_MILLIS = 60_000L + +private object BinaryClockRefreshScheduler { + fun scheduleNext(context: Context) { + val nextMinute = System.currentTimeMillis().let { now -> + now - (now % MINUTE_MILLIS) + MINUTE_MILLIS + } + context.getSystemService(AlarmManager::class.java) + .setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextMinute, pendingIntent(context)) + } + + fun cancel(context: Context) { + context.getSystemService(AlarmManager::class.java).cancel(pendingIntent(context)) + } + + private fun pendingIntent(context: Context): PendingIntent { + val intent = Intent(context, BinaryClockWidgetReceiver::class.java) + .setAction(ACTION_REFRESH_BINARY_CLOCK) + return PendingIntent.getBroadcast( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } +} diff --git a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml index 9062d18..de600ac 100644 --- a/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml +++ b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml @@ -1,5 +1,5 @@ - + From e2ff4ef9ef6068b256f782cf20fa4285040440c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:22:07 +0000 Subject: [PATCH 13/21] Address validation review feedback Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 5 +++-- README.md | 2 +- binaryclock/src/androidMain/AndroidManifest.xml | 2 ++ .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 4e7f791..56b3891 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -220,12 +220,13 @@ jobs: fi # Suppress the expected "not found" error when the release has not been created yet. - if gh release view "$release_tag" >/dev/null 2>&1; then + if gh release view "$release_tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then echo "Uploading APK to existing release $release_tag." else gh release create "$release_tag" \ + --repo "$GITHUB_REPOSITORY" \ --title "$release_tag" \ --notes "Signed APK built from ${GITHUB_SHA} with versionCode ${VERSION_CODE} and versionName ${VERSION_NAME}." fi - gh release upload "$release_tag" "$RUNNER_TEMP/$APK_NAME" --clobber + gh release upload "$release_tag" "$RUNNER_TEMP/$APK_NAME" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/README.md b/README.md index 5475012..19a1d27 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The `binaryclock` module provides a minimal Android home screen widget built wit The widget renders the current 24-hour time as six columns for `HHMMSS`. Each decimal digit is shown as four vertical bits with values `8`, `4`, `2`, and `1` from top to bottom. Bright dots are active bits and muted dots are inactive bits, using a dark monochrome style. -Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. +Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. The module declares `android.permission.SCHEDULE_EXACT_ALARM` for these clock refresh alarms. Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: diff --git a/binaryclock/src/androidMain/AndroidManifest.xml b/binaryclock/src/androidMain/AndroidManifest.xml index 5158e52..818874f 100644 --- a/binaryclock/src/androidMain/AndroidManifest.xml +++ b/binaryclock/src/androidMain/AndroidManifest.xml @@ -1,6 +1,8 @@ + + ) { - val isCompactMode = LocalSize.current.width <= 180.dp + val isCompactMode = LocalSize.current.width <= MinimumWidgetWidth Column( modifier = GlanceModifier .fillMaxSize() @@ -139,6 +139,7 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { } private val labels = listOf("H", "H", "M", "M", "S", "S") +private val MinimumWidgetWidth = 180.dp private const val ACTION_REFRESH_BINARY_CLOCK = "info.anodsplace.binaryclock.action.REFRESH" private const val MINUTE_MILLIS = 60_000L From 497b745d1aba470ccbd87a74624139a4a6b471dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:22:56 +0000 Subject: [PATCH 14/21] Handle missing exact alarm access Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- README.md | 2 +- .../anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 19a1d27..e756e35 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The `binaryclock` module provides a minimal Android home screen widget built wit The widget renders the current 24-hour time as six columns for `HHMMSS`. Each decimal digit is shown as four vertical bits with values `8`, `4`, `2`, and `1` from top to bottom. Bright dots are active bits and muted dots are inactive bits, using a dark monochrome style. -Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. The module declares `android.permission.SCHEDULE_EXACT_ALARM` for these clock refresh alarms. +Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. The module declares `android.permission.SCHEDULE_EXACT_ALARM` for these clock refresh alarms and falls back to inexact alarms if exact alarms are not allowed. Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index dbb694f..25a0ce7 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -149,8 +149,13 @@ private object BinaryClockRefreshScheduler { val nextMinute = System.currentTimeMillis().let { now -> now - (now % MINUTE_MILLIS) + MINUTE_MILLIS } - context.getSystemService(AlarmManager::class.java) - .setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextMinute, pendingIntent(context)) + val alarmManager = context.getSystemService(AlarmManager::class.java) + val refreshIntent = pendingIntent(context) + if (alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextMinute, refreshIntent) + } else { + alarmManager.set(AlarmManager.RTC_WAKEUP, nextMinute, refreshIntent) + } } fun cancel(context: Context) { From 143cce73883e33e2dc6503a98d68dc7cf8e414da Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:23:29 +0000 Subject: [PATCH 15/21] Use non-wakeup alarm refresh fallback Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index 25a0ce7..b470f1b 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -4,6 +4,7 @@ import android.app.AlarmManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp @@ -151,10 +152,10 @@ private object BinaryClockRefreshScheduler { } val alarmManager = context.getSystemService(AlarmManager::class.java) val refreshIntent = pendingIntent(context) - if (alarmManager.canScheduleExactAlarms()) { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextMinute, refreshIntent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && alarmManager.canScheduleExactAlarms()) { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC, nextMinute, refreshIntent) } else { - alarmManager.set(AlarmManager.RTC_WAKEUP, nextMinute, refreshIntent) + alarmManager.set(AlarmManager.RTC, nextMinute, refreshIntent) } } From 5889ce94b79ea9e23f78d6d14549064471bb7e46 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:24:17 +0000 Subject: [PATCH 16/21] Refine alarm fallback and publish condition Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 2 +- README.md | 2 +- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 56b3891..03d68b4 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -191,7 +191,7 @@ jobs: name: Publish APK to GitHub Release runs-on: ubuntu-latest needs: build - if: needs.build.outputs.build_apk == 'true' && (github.ref_type == 'tag' || github.event.inputs.publish_release == 'true') + if: needs.build.outputs.build_apk == 'true' && github.event.inputs.publish_release == 'true' permissions: contents: write env: diff --git a/README.md b/README.md index e756e35..12daa77 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The `binaryclock` module provides a minimal Android home screen widget built wit The widget renders the current 24-hour time as six columns for `HHMMSS`. Each decimal digit is shown as four vertical bits with values `8`, `4`, `2`, and `1` from top to bottom. Bright dots are active bits and muted dots are inactive bits, using a dark monochrome style. -Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. The module declares `android.permission.SCHEDULE_EXACT_ALARM` for these clock refresh alarms and falls back to inexact alarms if exact alarms are not allowed. +Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml`. Android launchers and the OS throttle app widget updates, so the widget schedules minute refreshes through its receiver instead of relying on the platform periodic app widget update mechanism. The module declares `android.permission.SCHEDULE_EXACT_ALARM` for these clock refresh alarms and falls back to inexact alarm windows if exact alarms are not allowed, which can reduce refresh accuracy. Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index b470f1b..187329b 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -155,7 +155,7 @@ private object BinaryClockRefreshScheduler { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && alarmManager.canScheduleExactAlarms()) { alarmManager.setAndAllowWhileIdle(AlarmManager.RTC, nextMinute, refreshIntent) } else { - alarmManager.set(AlarmManager.RTC, nextMinute, refreshIntent) + alarmManager.setWindow(AlarmManager.RTC, nextMinute, MINUTE_MILLIS, refreshIntent) } } From 1fb12f3bf0d2fee28b4e217cab136e08ebe17e4c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:25:13 +0000 Subject: [PATCH 17/21] Refine clock refresh alarm scheduling Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index 187329b..d6e1fd0 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -144,6 +144,7 @@ private val MinimumWidgetWidth = 180.dp private const val ACTION_REFRESH_BINARY_CLOCK = "info.anodsplace.binaryclock.action.REFRESH" private const val MINUTE_MILLIS = 60_000L +private const val INEXACT_REFRESH_WINDOW_MILLIS = 10_000L private object BinaryClockRefreshScheduler { fun scheduleNext(context: Context) { @@ -153,9 +154,9 @@ private object BinaryClockRefreshScheduler { val alarmManager = context.getSystemService(AlarmManager::class.java) val refreshIntent = pendingIntent(context) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && alarmManager.canScheduleExactAlarms()) { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC, nextMinute, refreshIntent) + alarmManager.setExact(AlarmManager.RTC, nextMinute, refreshIntent) } else { - alarmManager.setWindow(AlarmManager.RTC, nextMinute, MINUTE_MILLIS, refreshIntent) + alarmManager.setWindow(AlarmManager.RTC, nextMinute, INEXACT_REFRESH_WINDOW_MILLIS, refreshIntent) } } From 78084c835cec54c4498a5be1394e592b817929f3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:25:52 +0000 Subject: [PATCH 18/21] Use API 31 exact alarm guard Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .../info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index d6e1fd0..baa61a4 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -153,7 +153,7 @@ private object BinaryClockRefreshScheduler { } val alarmManager = context.getSystemService(AlarmManager::class.java) val refreshIntent = pendingIntent(context) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && alarmManager.canScheduleExactAlarms()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) { alarmManager.setExact(AlarmManager.RTC, nextMinute, refreshIntent) } else { alarmManager.setWindow(AlarmManager.RTC, nextMinute, INEXACT_REFRESH_WINDOW_MILLIS, refreshIntent) From d2161bb1e78ef773b4b5e1bc5fb474ff20ee57c2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:26:46 +0000 Subject: [PATCH 19/21] Align workflow versioning with dispatch triggers Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 15 +-------------- README.md | 2 +- .../binaryclock/BinaryClockGlanceWidget.kt | 4 ++-- 3 files changed, 4 insertions(+), 17 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 03d68b4..0b04e80 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -93,18 +93,7 @@ jobs: fi fi - if [ "${GITHUB_REF_TYPE}" = "tag" ]; then - version_name="${GITHUB_REF_NAME#v}" - case "$version_name" in - [0-9]*) ;; - *) - echo "Tag builds must use a version tag in the form v, for example v1.0.0." >&2 - exit 1 - ;; - esac - else - version_name="0.1.${GITHUB_RUN_NUMBER}" - fi + version_name="0.1.${GITHUB_RUN_NUMBER}" { echo "MODULE_PATH=$module_path" @@ -213,8 +202,6 @@ jobs: set -euo pipefail if [ -n "$REQUESTED_RELEASE_TAG" ]; then release_tag="$REQUESTED_RELEASE_TAG" - elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then - release_tag="$GITHUB_REF_NAME" else release_tag="apk-${VERSION_CODE}" fi diff --git a/README.md b/README.md index 12daa77..038883f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,6 @@ The workflow reuses the same signing key between builds through repository secre - `ANDROID_SIGNING_KEY_ALIAS` - `ANDROID_SIGNING_KEY_PASSWORD` -The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Manual runs started from `v` tags, such as `v1.0.0`, use the tag name without the leading `v` as the version name; manual non-tag builds use `0.1.`. +The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. APK version names use `0.1.`. Manual release runs can publish to a specific GitHub Release by setting `release_tag`; otherwise the workflow uses `apk-`. Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual release runs require a root Gradle wrapper and application module for the APK build to succeed. diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index baa61a4..1a51891 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -82,7 +82,7 @@ class BinaryClockWidgetReceiver : GlanceAppWidgetReceiver() { @Composable internal fun BinaryClockWidgetContent(digits: List) { - val isCompactMode = LocalSize.current.width <= MinimumWidgetWidth + val isCompactMode = LocalSize.current.width < RegularLayoutMinimumWidth Column( modifier = GlanceModifier .fillMaxSize() @@ -140,7 +140,7 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { } private val labels = listOf("H", "H", "M", "M", "S", "S") -private val MinimumWidgetWidth = 180.dp +private val RegularLayoutMinimumWidth = 181.dp private const val ACTION_REFRESH_BINARY_CLOCK = "info.anodsplace.binaryclock.action.REFRESH" private const val MINUTE_MILLIS = 60_000L From a9f58549cde7431254ac48d3774187f32a09b869 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:27:58 +0000 Subject: [PATCH 20/21] Checkpoint review fixes before APK publish work Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- README.md | 1 + .../anodsplace/binaryclock/BinaryClockGlanceWidget.kt | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 038883f..705ca89 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Build and test this module from the consuming Gradle project that includes this ```sh ./gradlew :lib:binaryclock:check +./gradlew :binaryclock:check ``` ## APK build and publish workflow diff --git a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt index 1a51891..738072c 100644 --- a/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -82,7 +82,7 @@ class BinaryClockWidgetReceiver : GlanceAppWidgetReceiver() { @Composable internal fun BinaryClockWidgetContent(digits: List) { - val isCompactMode = LocalSize.current.width < RegularLayoutMinimumWidth + val isCompactMode = LocalSize.current.width < REGULAR_LAYOUT_MINIMUM_WIDTH Column( modifier = GlanceModifier .fillMaxSize() @@ -111,8 +111,8 @@ internal fun BinaryClockWidgetContent(digits: List) { @Composable private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { - val dotSize = if (compact) 16.dp else 20.dp - val dotFontSize = if (compact) 14.sp else 18.sp + val dotSize = if (compact) 16.dp else 18.dp + val dotFontSize = if (compact) 14.sp else 16.sp Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -140,7 +140,7 @@ private fun BinaryDigitColumn(digit: Int, label: String, compact: Boolean) { } private val labels = listOf("H", "H", "M", "M", "S", "S") -private val RegularLayoutMinimumWidth = 181.dp +private val REGULAR_LAYOUT_MINIMUM_WIDTH = 180.dp private const val ACTION_REFRESH_BINARY_CLOCK = "info.anodsplace.binaryclock.action.REFRESH" private const val MINUTE_MILLIS = 60_000L From 9bc16143a044a6489de58dcb46b66da0658e3480 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 20:40:12 +0000 Subject: [PATCH 21/21] Publish fallback APK artifact Agent-Logs-Url: https://github.com/anod/android-framework-lib/sessions/e881dd84-5e8e-48e9-a8ae-202342cbc1cb Co-authored-by: anod <171704+anod@users.noreply.github.com> --- .github/workflows/build-publish-apk.yml | 232 +++++++++++++++++++++--- README.md | 7 +- 2 files changed, 205 insertions(+), 34 deletions(-) diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml index 0b04e80..2ac4ca8 100644 --- a/.github/workflows/build-publish-apk.yml +++ b/.github/workflows/build-publish-apk.yml @@ -17,6 +17,9 @@ on: description: Release tag to publish to when publish_release is enabled; defaults to apk- required: false default: '' + push: + tags: + - 'v*' permissions: contents: read @@ -67,38 +70,39 @@ jobs: id: metadata run: | set -euo pipefail - if [ ! -x ./gradlew ]; then - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - echo "No Gradle wrapper found at the repository root; skipping APK build for this pull request." - echo "BUILD_APK=false" >> "$GITHUB_ENV" - echo "build_apk=false" >> "$GITHUB_OUTPUT" - exit 0 - else - echo "This workflow requires a Gradle wrapper at the repository root." >&2 - exit 1 - fi - fi - module_path="${APP_MODULE#:}" module_dir="${module_path//:/\/}" - if [ ! -d "$module_dir" ]; then - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - echo "Android application module '$APP_MODULE' was not found at '$module_dir'; skipping APK build for this pull request." - echo "BUILD_APK=false" >> "$GITHUB_ENV" - echo "build_apk=false" >> "$GITHUB_OUTPUT" - exit 0 - else - echo "Android application module '$APP_MODULE' was not found at '$module_dir'." >&2 - exit 1 - fi + fallback_apk=false + if [ ! -x ./gradlew ]; then + echo "No Gradle wrapper found at the repository root; building the Android SDK fallback APK." + fallback_apk=true + module_path="binaryclock-demo" + module_dir="." + elif [ ! -d "$module_dir" ]; then + echo "Android application module '$APP_MODULE' was not found at '$module_dir'; building the Android SDK fallback APK." + fallback_apk=true + module_path="binaryclock-demo" + module_dir="." fi - version_name="0.1.${GITHUB_RUN_NUMBER}" + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + version_name="${GITHUB_REF_NAME#v}" + case "$version_name" in + [0-9]*) ;; + *) + echo "Tag builds must use a version tag in the form v, for example v1.0.0." >&2 + exit 1 + ;; + esac + else + version_name="0.1.${GITHUB_RUN_NUMBER}" + fi { echo "MODULE_PATH=$module_path" echo "MODULE_DIR=$module_dir" echo "BUILD_APK=true" + echo "FALLBACK_ANDROID_SDK_APK=$fallback_apk" echo "VERSION_CODE=${GITHUB_RUN_NUMBER}" echo "VERSION_NAME=$version_name" echo "APK_NAME=${module_path//:/-}-${version_name}-${GITHUB_RUN_NUMBER}.apk" @@ -110,10 +114,10 @@ jobs: echo "version_name=$version_name" } >> "$GITHUB_OUTPUT" - - name: Report skipped APK build - if: env.BUILD_APK != 'true' + - name: Report fallback APK build + if: env.FALLBACK_ANDROID_SDK_APK == 'true' run: | - echo "No APK build was run because this repository checkout does not contain a root Gradle application project." >> "$GITHUB_STEP_SUMMARY" + echo "No root Gradle application project was found, so this run will publish a minimal Binary Clock demo APK built with Android SDK tools." >> "$GITHUB_STEP_SUMMARY" - name: Decode signing keystore if: env.BUILD_APK == 'true' && github.event_name != 'pull_request' @@ -126,7 +130,7 @@ jobs: echo "KEYSTORE_FILE=$keystore_file" >> "$GITHUB_ENV" - name: Build unsigned release APK - if: env.BUILD_APK == 'true' && github.event_name == 'pull_request' + if: env.BUILD_APK == 'true' && env.FALLBACK_ANDROID_SDK_APK != 'true' && github.event_name == 'pull_request' run: | set -euo pipefail ./gradlew ":${MODULE_PATH}:assembleRelease" \ @@ -135,7 +139,7 @@ jobs: -Pandroid.injected.version.name="$VERSION_NAME" - name: Build signed release APK - if: env.BUILD_APK == 'true' && github.event_name != 'pull_request' + if: env.BUILD_APK == 'true' && env.FALLBACK_ANDROID_SDK_APK != 'true' && github.event_name != 'pull_request' env: ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} @@ -151,8 +155,174 @@ jobs: -Pandroid.injected.signing.key.alias="$ANDROID_SIGNING_KEY_ALIAS" \ -Pandroid.injected.signing.key.password="$ANDROID_SIGNING_KEY_PASSWORD" + - name: Build Android SDK fallback APK + if: env.FALLBACK_ANDROID_SDK_APK == 'true' + env: + ANDROID_SIGNING_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEYSTORE_PASSWORD }} + ANDROID_SIGNING_KEY_ALIAS: ${{ secrets.ANDROID_SIGNING_KEY_ALIAS }} + ANDROID_SIGNING_KEY_PASSWORD: ${{ secrets.ANDROID_SIGNING_KEY_PASSWORD }} + run: | + set -euo pipefail + build_tools_dir="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d | sort -V | tail -n 1)" + android_jar="$ANDROID_HOME/platforms/android-36/android.jar" + work_dir="$RUNNER_TEMP/binary-clock-apk" + src_dir="$work_dir/src" + compiled_res="$work_dir/compiled-res" + generated_src="$work_dir/generated" + classes_dir="$work_dir/classes" + dex_dir="$work_dir/dex" + unsigned_apk="$work_dir/unsigned.apk" + aligned_apk="$work_dir/aligned.apk" + published_apk="$RUNNER_TEMP/$APK_NAME" + + rm -rf "$work_dir" + mkdir -p \ + "$src_dir/java/info/anodsplace/binaryclock/demo" \ + "$src_dir/res/layout" \ + "$src_dir/res/values" \ + "$src_dir/res/xml" \ + "$compiled_res" \ + "$generated_src" \ + "$classes_dir" \ + "$dex_dir" + + cat > "$src_dir/AndroidManifest.xml" <<'EOF' + + + + + + + + + + + EOF + cat > "$src_dir/res/values/strings.xml" <<'EOF' + + Binary Clock + Binary clock demo widget + + EOF + cat > "$src_dir/res/layout/binary_clock_widget.xml" <<'EOF' + + EOF + cat > "$src_dir/res/xml/binary_clock_widget_info.xml" <<'EOF' + + EOF + cat > "$src_dir/java/info/anodsplace/binaryclock/demo/BinaryClockWidgetProvider.java" <<'EOF' + package info.anodsplace.binaryclock.demo; + + import android.appwidget.AppWidgetManager; + import android.appwidget.AppWidgetProvider; + import android.content.Context; + import android.widget.RemoteViews; + import java.time.LocalTime; + + public final class BinaryClockWidgetProvider extends AppWidgetProvider { + @Override + public void onUpdate(Context context, AppWidgetManager manager, int[] appWidgetIds) { + for (int appWidgetId : appWidgetIds) { + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.binary_clock_widget); + views.setTextViewText(R.id.binary_clock_text, binaryTime(LocalTime.now())); + manager.updateAppWidget(appWidgetId, views); + } + } + + private static String binaryTime(LocalTime time) { + return digit(time.getHour() / 10) + " " + digit(time.getHour() % 10) + "\n" + + digit(time.getMinute() / 10) + " " + digit(time.getMinute() % 10) + "\n" + + digit(time.getSecond() / 10) + " " + digit(time.getSecond() % 10); + } + + private static String digit(int value) { + return "" + bit(value, 8) + bit(value, 4) + bit(value, 2) + bit(value, 1); + } + + private static char bit(int value, int bit) { + return (value & bit) == bit ? '1' : '0'; + } + } + EOF + + "$build_tools_dir/aapt2" compile --dir "$src_dir/res" -o "$compiled_res" + "$build_tools_dir/aapt2" link \ + -I "$android_jar" \ + --manifest "$src_dir/AndroidManifest.xml" \ + --java "$generated_src" \ + --min-sdk-version 31 \ + --target-sdk-version 36 \ + --version-code "$VERSION_CODE" \ + --version-name "$VERSION_NAME" \ + --rename-manifest-package info.anodsplace.binaryclock.demo \ + -o "$unsigned_apk" \ + "$compiled_res"/*.flat + javac \ + -source 17 \ + -target 17 \ + -classpath "$android_jar:$generated_src" \ + -d "$classes_dir" \ + "$generated_src/info/anodsplace/binaryclock/demo/R.java" \ + "$src_dir/java/info/anodsplace/binaryclock/demo/BinaryClockWidgetProvider.java" + mapfile -t class_files < <(find "$classes_dir" -name '*.class' -type f) + "$build_tools_dir/d8" --min-api 31 --output "$dex_dir" "${class_files[@]}" + (cd "$dex_dir" && zip -q "$unsigned_apk" classes.dex) + "$build_tools_dir/zipalign" -f 4 "$unsigned_apk" "$aligned_apk" + + if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then + debug_keystore="$RUNNER_TEMP/binary-clock-debug.keystore" + keytool -genkeypair \ + -keystore "$debug_keystore" \ + -storepass android \ + -keypass android \ + -alias androiddebugkey \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -dname "CN=Android Debug,O=Android,C=US" + "$build_tools_dir/apksigner" sign \ + --ks "$debug_keystore" \ + --ks-pass pass:android \ + --key-pass pass:android \ + --out "$published_apk" \ + "$aligned_apk" + else + "$build_tools_dir/apksigner" sign \ + --ks "$KEYSTORE_FILE" \ + --ks-pass pass:"$ANDROID_SIGNING_KEYSTORE_PASSWORD" \ + --ks-key-alias "$ANDROID_SIGNING_KEY_ALIAS" \ + --key-pass pass:"$ANDROID_SIGNING_KEY_PASSWORD" \ + --out "$published_apk" \ + "$aligned_apk" + fi + + "$build_tools_dir/apksigner" verify "$published_apk" + echo "APK_PATH=$published_apk" >> "$GITHUB_ENV" + - name: Locate APK - if: env.BUILD_APK == 'true' + if: env.BUILD_APK == 'true' && env.FALLBACK_ANDROID_SDK_APK != 'true' run: | set -euo pipefail if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then @@ -180,7 +350,7 @@ jobs: name: Publish APK to GitHub Release runs-on: ubuntu-latest needs: build - if: needs.build.outputs.build_apk == 'true' && github.event.inputs.publish_release == 'true' + if: needs.build.outputs.build_apk == 'true' && (github.ref_type == 'tag' || github.event.inputs.publish_release == 'true') permissions: contents: write env: @@ -202,6 +372,8 @@ jobs: set -euo pipefail if [ -n "$REQUESTED_RELEASE_TAG" ]; then release_tag="$REQUESTED_RELEASE_TAG" + elif [ "${GITHUB_REF_TYPE}" = "tag" ]; then + release_tag="$GITHUB_REF_NAME" else release_tag="apk-${VERSION_CODE}" fi diff --git a/README.md b/README.md index 705ca89..5dbc145 100644 --- a/README.md +++ b/README.md @@ -26,13 +26,12 @@ Widget metadata lives in `binaryclock/src/androidMain/res/xml/binary_clock_widge Build and test this module from the consuming Gradle project that includes this repository, for example with the module path configured by that project: ```sh -./gradlew :lib:binaryclock:check ./gradlew :binaryclock:check ``` ## APK build and publish workflow -`.github/workflows/build-publish-apk.yml` builds a release APK for an Android application module on pull requests and manual runs, then uploads it as a workflow artifact. Pull request builds do not use signing secrets or publish releases. When `publish_release` is enabled for a manual run, it signs the APK and uploads it to a GitHub Release. +`.github/workflows/build-publish-apk.yml` builds a release APK for an Android application module on pull requests, manual runs, and `v*` tags, then uploads it as a workflow artifact. Pull request builds use a debug signature and do not publish releases. When `publish_release` is enabled for a manual run, or when a `v*` tag is pushed, it signs the APK and uploads it to a GitHub Release. The workflow reuses the same signing key between builds through repository secrets. Generate the release keystore once, base64-encode it, and store these secrets in GitHub: @@ -41,6 +40,6 @@ The workflow reuses the same signing key between builds through repository secre - `ANDROID_SIGNING_KEY_ALIAS` - `ANDROID_SIGNING_KEY_PASSWORD` -The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. APK version names use `0.1.`. Manual release runs can publish to a specific GitHub Release by setting `release_tag`; otherwise the workflow uses `apk-`. +The APK version code is automatically set to `github.run_number`, so each workflow run increments the version code. Tagged builds use the tag name without a leading `v` as the version name; non-tag builds use `0.1.`. Manual release runs can publish to a specific GitHub Release by setting `release_tag`; otherwise the workflow uses `apk-`. -Manual runs accept an Android application module path, defaulting to `app`. This repository snapshot contains Android library modules only, so pull request runs report that no APK build was run unless a root Gradle wrapper and application module are added. Manual release runs require a root Gradle wrapper and application module for the APK build to succeed. +Manual runs accept an Android application module path, defaulting to `app`. If this repository snapshot does not contain a root Gradle wrapper or Android application module, the workflow still publishes a minimal Binary Clock demo APK built directly with Android SDK tools.