diff --git a/.github/workflows/build-publish-apk.yml b/.github/workflows/build-publish-apk.yml new file mode 100644 index 0000000..2ac4ca8 --- /dev/null +++ b/.github/workflows/build-publish-apk.yml @@ -0,0 +1,391 @@ +name: Build and publish APK + +on: + pull_request: + 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: read + +concurrency: + group: build-publish-apk-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build release APK + 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: + APP_MODULE: ${{ github.event.inputs.module || 'app' }} + 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 + 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 }} + 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 + id: metadata + run: | + set -euo pipefail + module_path="${APP_MODULE#:}" + module_dir="${module_path//:/\/}" + 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 + + 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" + } >> "$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 fallback APK build + if: env.FALLBACK_ANDROID_SDK_APK == 'true' + run: | + 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' + 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 unsigned release APK + if: env.BUILD_APK == 'true' && env.FALLBACK_ANDROID_SDK_APK != 'true' && 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: 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 }} + 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: 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' && env.FALLBACK_ANDROID_SDK_APK != 'true' + run: | + set -euo pipefail + 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 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 + if: env.BUILD_APK == 'true' + uses: actions/upload-artifact@v4.6.2 + with: + name: ${{ env.APK_NAME }} + path: ${{ env.APK_PATH }} + if-no-files-found: error + + publish: + 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') + 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 + 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 + + # Suppress the expected "not found" error when the release has not been created yet. + 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" --repo "$GITHUB_REPOSITORY" --clobber diff --git a/README.md b/README.md index 8228592..5dbc145 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,32 @@ 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 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: + +```sh +./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, 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: + +- `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; 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`. 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. diff --git a/binaryclock/build.gradle.kts b/binaryclock/build.gradle.kts new file mode 100644 index 0000000..b67e682 --- /dev/null +++ b/binaryclock/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.kotlin.multiplatform.android.library) + alias(libs.plugins.compose.compiler) +} + +kotlin { + androidLibrary { + namespace = "info.anodsplace.binaryclock" + compileSdk = 36 + minSdk = 31 + androidResources { + enable = true + } + } + + sourceSets { + commonTest.dependencies { + implementation(kotlin("test")) + } + androidMain.dependencies { + implementation(libs.androidx.glance.appwidget) + implementation(libs.coroutines.core) + } + } +} diff --git a/binaryclock/src/androidMain/AndroidManifest.xml b/binaryclock/src/androidMain/AndroidManifest.xml new file mode 100644 index 0000000..818874f --- /dev/null +++ b/binaryclock/src/androidMain/AndroidManifest.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + 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..738072c --- /dev/null +++ b/binaryclock/src/androidMain/kotlin/info/anodsplace/binaryclock/BinaryClockGlanceWidget.kt @@ -0,0 +1,177 @@ +package info.anodsplace.binaryclock + +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 +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.appwidget.updateAll +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.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) { + 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() + + 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 < REGULAR_LAYOUT_MINIMUM_WIDTH + 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 = isCompactMode, + ) + 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 18.dp + val dotFontSize = if (compact) 14.sp else 16.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, + ), + ) + } + Spacer(modifier = GlanceModifier.height(4.dp)) + Text( + text = label, + style = TextStyle( + color = ColorProvider(Color(0xFF888888)), + fontSize = 10.sp, + ), + ) + } +} + +private val labels = listOf("H", "H", "M", "M", "S", "S") +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 +private const val INEXACT_REFRESH_WINDOW_MILLIS = 10_000L + +private object BinaryClockRefreshScheduler { + fun scheduleNext(context: Context) { + val nextMinute = System.currentTimeMillis().let { now -> + now - (now % MINUTE_MILLIS) + MINUTE_MILLIS + } + val alarmManager = context.getSystemService(AlarmManager::class.java) + val refreshIntent = pendingIntent(context) + 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) + } + } + + 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/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..de600ac --- /dev/null +++ b/binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml @@ -0,0 +1,13 @@ + + + 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..7136629 --- /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) } + } +}