Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
391 changes: 391 additions & 0 deletions .github/workflows/build-publish-apk.yml

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<run_number>`. Manual release runs can publish to a specific GitHub Release by setting `release_tag`; otherwise the workflow uses `apk-<versionCode>`.

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.
26 changes: 26 additions & 0 deletions binaryclock/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
18 changes: 18 additions & 0 deletions binaryclock/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

<application>
<receiver
android:name=".BinaryClockWidgetReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/binary_clock_widget_info" />
</receiver>
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -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<Int>) {
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,
)
}
}
4 changes: 4 additions & 0 deletions binaryclock/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="binary_clock_widget_description">Binary clock widget</string>
</resources>
13 changes: 13 additions & 0 deletions binaryclock/src/androidMain/res/xml/binary_clock_widget_info.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Minute refreshes are scheduled by BinaryClockWidgetReceiver. -->
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/binary_clock_widget_description"
android:initialLayout="@layout/glance_default_loading_layout"
android:minWidth="180dp"
android:minHeight="110dp"
android:previewLayout="@layout/glance_default_loading_layout"
android:resizeMode="horizontal|vertical"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:updatePeriodMillis="0"
android:widgetCategory="home_screen" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package info.anodsplace.binaryclock

object BinaryClockDigits {
val bitValues = listOf(8, 4, 2, 1)

fun digitBits(digit: Int): List<Boolean> {
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<Int> {
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,
)
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { BinaryClockDigits.digitBits(10) }
assertFailsWith<IllegalArgumentException> { BinaryClockDigits.timeDigits(24, 0, 0) }
assertFailsWith<IllegalArgumentException> { BinaryClockDigits.timeDigits(0, 60, 0) }
assertFailsWith<IllegalArgumentException> { BinaryClockDigits.timeDigits(0, 0, 60) }
}
}
Loading