@glancekit/android-core 0.1.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # @glancekit/android-core
2
+
3
+ Pure Android core module for GlanceKit alpha.
4
+
5
+ This module owns the pure Android implementation:
6
+
7
+ - `ProgressCardData`
8
+ - `WidgetStateRepository`
9
+ - `WidgetUpdateManager`
10
+ - `ProgressCardWidget`
11
+ - `ProgressCardWidgetReceiver`
12
+
13
+ It intentionally does not depend on React Native.
14
+
15
+ ## Intended Use
16
+
17
+ Most React Native and Expo consumers should install:
18
+
19
+ - `@glancekit/react-native`
20
+ - `@glancekit/expo-plugin` for Expo managed builds
21
+
22
+ This package exists so the Android implementation has a clean boundary and can be consumed directly by:
23
+
24
+ - the native Android demo in `examples/native-android-demo`
25
+ - `@glancekit/react-native` as a bridge dependency
26
+
27
+ ## Alpha Notes
28
+
29
+ - Android only
30
+ - internal/power-user package for alpha
31
+ - `ProgressCardWidget` is the only widget template
32
+ - no React Native JS API is exposed from this package
@@ -0,0 +1,51 @@
1
+ import org.jetbrains.kotlin.config.KotlinCompilerVersion
2
+
3
+ plugins {
4
+ id("com.android.library")
5
+ id("org.jetbrains.kotlin.android")
6
+ }
7
+
8
+ val composeExpectedKotlinVersion = "1.9.24"
9
+ val detectedKotlinVersion = KotlinCompilerVersion.VERSION
10
+
11
+ android {
12
+ namespace = "dev.glancekit.androidcore"
13
+ compileSdk = 34
14
+
15
+ defaultConfig {
16
+ minSdk = 26
17
+ consumerProguardFiles("consumer-rules.pro")
18
+ }
19
+
20
+ buildFeatures {
21
+ compose = true
22
+ }
23
+
24
+ compileOptions {
25
+ sourceCompatibility = JavaVersion.VERSION_17
26
+ targetCompatibility = JavaVersion.VERSION_17
27
+ }
28
+
29
+ composeOptions {
30
+ kotlinCompilerExtensionVersion = "1.5.14"
31
+ }
32
+
33
+ kotlinOptions {
34
+ jvmTarget = "17"
35
+ if (detectedKotlinVersion != composeExpectedKotlinVersion) {
36
+ freeCompilerArgs += listOf(
37
+ "-P",
38
+ "plugin:androidx.compose.compiler.plugins.kotlin:suppressKotlinVersionCompatibilityCheck=$detectedKotlinVersion",
39
+ )
40
+ }
41
+ }
42
+ }
43
+
44
+ dependencies {
45
+ implementation("androidx.core:core-ktx:1.13.1")
46
+ implementation("androidx.datastore:datastore-preferences:1.1.1")
47
+ api("androidx.glance:glance-appwidget:1.1.1")
48
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
49
+
50
+ testImplementation("junit:junit:4.13.2")
51
+ }
@@ -0,0 +1 @@
1
+
package/index.js ADDED
@@ -0,0 +1 @@
1
+ module.exports = {};
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@glancekit/android-core",
3
+ "version": "0.1.0-alpha.0",
4
+ "private": false,
5
+ "description": "Pure Android Glance/DataStore core module for GlanceKit alpha.",
6
+ "main": "index.js",
7
+ "files": [
8
+ "README.md",
9
+ "package.json",
10
+ "index.js",
11
+ "build.gradle.kts",
12
+ "consumer-rules.pro",
13
+ "src/main"
14
+ ],
15
+ "keywords": [
16
+ "glancekit",
17
+ "android",
18
+ "jetpack-glance",
19
+ "android-widget",
20
+ "datastore"
21
+ ],
22
+ "license": "MIT",
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "scripts": {
27
+ "build": "echo android-core build is driven by Gradle consumers",
28
+ "lint": "echo android-core lint pending",
29
+ "typecheck": "echo android-core typecheck pending"
30
+ },
31
+ "engines": {
32
+ "node": ">=18"
33
+ }
34
+ }
@@ -0,0 +1,29 @@
1
+ package dev.glancekit.androidcore
2
+
3
+ data class ProgressCardData(
4
+ val title: String,
5
+ val subtitle: String,
6
+ val progress: Int,
7
+ val deepLink: String? = null,
8
+ ) {
9
+ fun normalized(): ProgressCardData = copy(
10
+ title = title.trim(),
11
+ subtitle = subtitle.trim(),
12
+ deepLink = deepLink?.trim()?.takeIf { it.isNotEmpty() },
13
+ )
14
+
15
+ fun validate() {
16
+ require(title.isNotBlank()) { "Widget title cannot be empty." }
17
+ require(subtitle.isNotBlank()) { "Widget subtitle cannot be empty." }
18
+ require(progress in 0..100) { "Widget progress must be between 0 and 100." }
19
+ }
20
+
21
+ companion object {
22
+ val DEFAULT = ProgressCardData(
23
+ title = "Daily Progress",
24
+ subtitle = "Tap the app to update this widget.",
25
+ progress = 0,
26
+ deepLink = null,
27
+ )
28
+ }
29
+ }
@@ -0,0 +1,76 @@
1
+ package dev.glancekit.androidcore
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import androidx.datastore.preferences.core.edit
6
+ import androidx.datastore.preferences.core.intPreferencesKey
7
+ import androidx.datastore.preferences.core.stringPreferencesKey
8
+ import androidx.datastore.preferences.preferencesDataStore
9
+ import kotlinx.coroutines.flow.first
10
+
11
+ private val Context.widgetDataStore by preferencesDataStore(name = "glancekit_widgets")
12
+
13
+ internal object WidgetStateRepository {
14
+ suspend fun loadWidget(context: Context, widgetId: Int): ProgressCardData {
15
+ if (widgetId <= 0) {
16
+ Log.w(TAG, "loadWidget called with invalid widgetId=$widgetId, returning default state")
17
+ return ProgressCardData.DEFAULT
18
+ }
19
+
20
+ val preferences = context.widgetDataStore.data.first()
21
+ val data = ProgressCardData(
22
+ title = preferences[stringPreferencesKey(titleKey(widgetId))]
23
+ ?: ProgressCardData.DEFAULT.title,
24
+ subtitle = preferences[stringPreferencesKey(subtitleKey(widgetId))]
25
+ ?: ProgressCardData.DEFAULT.subtitle,
26
+ progress = preferences[intPreferencesKey(progressKey(widgetId))]
27
+ ?: ProgressCardData.DEFAULT.progress,
28
+ deepLink = preferences[stringPreferencesKey(deepLinkKey(widgetId))],
29
+ )
30
+ Log.d(
31
+ TAG,
32
+ "loadWidget widgetId=$widgetId title=${data.title} progress=${data.progress} deepLink=${data.deepLink}",
33
+ )
34
+ return data
35
+ }
36
+
37
+ suspend fun saveWidget(context: Context, widgetId: Int, data: ProgressCardData) {
38
+ Log.d(
39
+ TAG,
40
+ "saveWidget widgetId=$widgetId title=${data.title} progress=${data.progress} deepLink=${data.deepLink}",
41
+ )
42
+ context.widgetDataStore.edit { preferences ->
43
+ preferences[stringPreferencesKey(titleKey(widgetId))] = data.title
44
+ preferences[stringPreferencesKey(subtitleKey(widgetId))] = data.subtitle
45
+ preferences[intPreferencesKey(progressKey(widgetId))] = data.progress
46
+ val deepLinkKey = stringPreferencesKey(deepLinkKey(widgetId))
47
+ if (data.deepLink == null) {
48
+ preferences.remove(deepLinkKey)
49
+ } else {
50
+ preferences[deepLinkKey] = data.deepLink
51
+ }
52
+ }
53
+ }
54
+
55
+ suspend fun deleteWidget(context: Context, widgetId: Int) {
56
+ if (widgetId <= 0) {
57
+ Log.w(TAG, "deleteWidget skipped for invalid widgetId=$widgetId")
58
+ return
59
+ }
60
+
61
+ Log.d(TAG, "deleteWidget widgetId=$widgetId")
62
+ context.widgetDataStore.edit { preferences ->
63
+ preferences.remove(stringPreferencesKey(titleKey(widgetId)))
64
+ preferences.remove(stringPreferencesKey(subtitleKey(widgetId)))
65
+ preferences.remove(intPreferencesKey(progressKey(widgetId)))
66
+ preferences.remove(stringPreferencesKey(deepLinkKey(widgetId)))
67
+ }
68
+ }
69
+
70
+ private fun titleKey(widgetId: Int) = "widget_${widgetId}_title"
71
+ private fun subtitleKey(widgetId: Int) = "widget_${widgetId}_subtitle"
72
+ private fun progressKey(widgetId: Int) = "widget_${widgetId}_progress"
73
+ private fun deepLinkKey(widgetId: Int) = "widget_${widgetId}_deep_link"
74
+
75
+ private const val TAG = "GlanceKitState"
76
+ }
@@ -0,0 +1,102 @@
1
+ package dev.glancekit.androidcore
2
+
3
+ import android.content.Context
4
+ import android.util.Log
5
+ import androidx.glance.appwidget.GlanceAppWidgetManager
6
+ import androidx.glance.appwidget.updateAll
7
+ import dev.glancekit.androidcore.widget.ProgressCardWidget
8
+
9
+ object WidgetUpdateManager {
10
+ suspend fun updateProgressCardWidget(
11
+ context: Context,
12
+ widgetId: Int,
13
+ data: ProgressCardData,
14
+ widgetClass: Class<out ProgressCardWidget> = ProgressCardWidget::class.java,
15
+ ): ProgressCardData {
16
+ require(widgetId > 0) { "Widget ID must be a positive integer." }
17
+
18
+ Log.d(TAG, "updateProgressCardWidget start widgetId=$widgetId widgetClass=${widgetClass.name} rawData=$data")
19
+ val normalized = data.normalized()
20
+ normalized.validate()
21
+ WidgetStateRepository.saveWidget(context, widgetId, normalized)
22
+ refreshProgressCardWidget(context, widgetId, widgetClass)
23
+ Log.d(TAG, "updateProgressCardWidget success widgetId=$widgetId normalized=$normalized")
24
+ return normalized
25
+ }
26
+
27
+ suspend fun loadProgressCardWidget(
28
+ context: Context,
29
+ widgetId: Int,
30
+ ): ProgressCardData {
31
+ Log.d(TAG, "loadProgressCardWidget widgetId=$widgetId")
32
+ return WidgetStateRepository.loadWidget(context, widgetId)
33
+ }
34
+
35
+ suspend fun refreshProgressCardWidget(
36
+ context: Context,
37
+ widgetId: Int,
38
+ widgetClass: Class<out ProgressCardWidget> = ProgressCardWidget::class.java,
39
+ ) {
40
+ Log.d(TAG, "refreshProgressCardWidget widgetId=$widgetId widgetClass=${widgetClass.name}")
41
+ val manager = GlanceAppWidgetManager(context)
42
+ val glanceIds = manager.getGlanceIds(widgetClass)
43
+ Log.d(TAG, "refreshProgressCardWidget glanceIds count=${glanceIds.size} values=$glanceIds")
44
+ val widget = widgetClass.getDeclaredConstructor().newInstance()
45
+ glanceIds.forEach { glanceId ->
46
+ val appWidgetId = manager.getAppWidgetId(glanceId)
47
+ if (appWidgetId == widgetId) {
48
+ Log.d(TAG, "refreshProgressCardWidget matched glanceId=$glanceId appWidgetId=$appWidgetId")
49
+ widget.update(context, glanceId)
50
+ }
51
+ }
52
+ }
53
+
54
+ suspend fun refreshAllProgressCardWidgets(
55
+ context: Context,
56
+ widgetClass: Class<out ProgressCardWidget> = ProgressCardWidget::class.java,
57
+ ) {
58
+ Log.d(TAG, "refreshAllProgressCardWidgets widgetClass=${widgetClass.name}")
59
+ widgetClass.getDeclaredConstructor().newInstance().updateAll(context)
60
+ }
61
+
62
+ suspend fun updateAllProgressCardWidgets(
63
+ context: Context,
64
+ data: ProgressCardData,
65
+ widgetClass: Class<out ProgressCardWidget> = ProgressCardWidget::class.java,
66
+ ): Int {
67
+ val normalized = data.normalized()
68
+ normalized.validate()
69
+
70
+ val manager = GlanceAppWidgetManager(context)
71
+ val glanceIds = manager.getGlanceIds(widgetClass)
72
+ Log.d(
73
+ TAG,
74
+ "updateAllProgressCardWidgets widgetClass=${widgetClass.name} glanceIds count=${glanceIds.size} values=$glanceIds",
75
+ )
76
+ check(glanceIds.isNotEmpty()) {
77
+ "No ProgressCardWidget instances are currently added to the home screen. " +
78
+ "Searched for widgetClass=${widgetClass.name}."
79
+ }
80
+
81
+ Log.d(
82
+ TAG,
83
+ "updateAllProgressCardWidgets count=${glanceIds.size} normalized=$normalized",
84
+ )
85
+ val widget = widgetClass.getDeclaredConstructor().newInstance()
86
+ glanceIds.forEach { glanceId ->
87
+ val appWidgetId = manager.getAppWidgetId(glanceId)
88
+ WidgetStateRepository.saveWidget(context, appWidgetId, normalized)
89
+ Log.d(TAG, "updateAllProgressCardWidgets updating glanceId=$glanceId appWidgetId=$appWidgetId")
90
+ widget.update(context, glanceId)
91
+ }
92
+
93
+ return glanceIds.size
94
+ }
95
+
96
+ suspend fun deleteProgressCardWidget(context: Context, widgetId: Int) {
97
+ Log.d(TAG, "deleteProgressCardWidget widgetId=$widgetId")
98
+ WidgetStateRepository.deleteWidget(context, widgetId)
99
+ }
100
+
101
+ private const val TAG = "GlanceKitUpdate"
102
+ }
@@ -0,0 +1,185 @@
1
+ package dev.glancekit.androidcore.widget
2
+
3
+ import android.content.Context
4
+ import android.content.Intent
5
+ import android.net.Uri
6
+ import android.util.Log
7
+ import androidx.compose.runtime.Composable
8
+ import androidx.compose.ui.unit.dp
9
+ import androidx.glance.GlanceId
10
+ import androidx.glance.GlanceModifier
11
+ import androidx.glance.action.Action
12
+ import androidx.glance.action.clickable
13
+ import androidx.glance.appwidget.GlanceAppWidget
14
+ import androidx.glance.appwidget.GlanceAppWidgetManager
15
+ import androidx.glance.appwidget.action.actionStartActivity
16
+ import androidx.glance.appwidget.provideContent
17
+ import androidx.glance.layout.Column
18
+ import androidx.glance.layout.Spacer
19
+ import androidx.glance.layout.height
20
+ import androidx.glance.layout.padding
21
+ import androidx.glance.text.Text
22
+ import dev.glancekit.androidcore.ProgressCardData
23
+ import dev.glancekit.androidcore.WidgetUpdateManager
24
+ import kotlinx.coroutines.Dispatchers
25
+ import kotlinx.coroutines.withContext
26
+
27
+ open class ProgressCardWidget : GlanceAppWidget() {
28
+ override suspend fun provideGlance(context: Context, id: GlanceId) {
29
+ Log.d(TAG, "provideGlance start glanceId=$id")
30
+ try {
31
+ val renderState = loadRenderState(context, id)
32
+ Log.d(TAG, "provideGlance before provideContent glanceId=$id")
33
+ provideContent {
34
+ Log.d(
35
+ TAG,
36
+ "provideContent entered glanceId=$id mode=${renderState.mode} widgetId=${renderState.widgetId}",
37
+ )
38
+
39
+ when (renderState.mode) {
40
+ RenderMode.Fallback -> {
41
+ Log.d(TAG, "render static content fallback glanceId=$id")
42
+ MinimalFallbackContent()
43
+ }
44
+
45
+ RenderMode.Data -> {
46
+ Log.d(
47
+ TAG,
48
+ "render data content widgetId=${renderState.widgetId} title=${renderState.data.title}",
49
+ )
50
+ ProgressCardTextContent(
51
+ data = renderState.data,
52
+ onClick = renderState.onClick,
53
+ )
54
+ }
55
+ }
56
+ }
57
+ } catch (error: Throwable) {
58
+ Log.e(TAG, "provideGlance failure catch glanceId=$id", error)
59
+ throw error
60
+ }
61
+ }
62
+
63
+ private suspend fun loadRenderState(context: Context, id: GlanceId): RenderState {
64
+ return runCatching {
65
+ val manager = GlanceAppWidgetManager(context)
66
+ val widgetId = manager.getAppWidgetId(id)
67
+ Log.d(TAG, "state read start widgetId=$widgetId glanceId=$id")
68
+
69
+ val data = withContext(Dispatchers.IO) {
70
+ WidgetUpdateManager.loadProgressCardWidget(context, widgetId)
71
+ }.also { loadedData ->
72
+ Log.d(
73
+ TAG,
74
+ "state read success widgetId=$widgetId title=${loadedData.title} progress=${loadedData.progress}",
75
+ )
76
+ }
77
+
78
+ RenderState(
79
+ widgetId = widgetId,
80
+ data = data,
81
+ onClick = createClickAction(context, widgetId, data),
82
+ mode = RenderMode.Data,
83
+ )
84
+ }.getOrElse { error ->
85
+ Log.e(TAG, "state read failure glanceId=$id", error)
86
+ RenderState(
87
+ widgetId = -1,
88
+ data = ProgressCardData.DEFAULT,
89
+ onClick = null,
90
+ mode = RenderMode.Fallback,
91
+ )
92
+ }
93
+ }
94
+
95
+ private fun createClickAction(
96
+ context: Context,
97
+ widgetId: Int,
98
+ data: ProgressCardData,
99
+ ): Action? {
100
+ Log.d(TAG, "click action creation start widgetId=$widgetId")
101
+ return runCatching {
102
+ val intent = createDeepLinkIntent(context, widgetId, data)
103
+ actionStartActivity(intent).also {
104
+ Log.d(TAG, "click action creation success widgetId=$widgetId uri=${intent.data}")
105
+ }
106
+ }.getOrElse { error ->
107
+ Log.e(TAG, "click action creation failure widgetId=$widgetId", error)
108
+ null
109
+ }
110
+ }
111
+
112
+ private fun createDeepLinkIntent(
113
+ context: Context,
114
+ widgetId: Int,
115
+ data: ProgressCardData,
116
+ ): Intent {
117
+ val deepLinkUri = data.deepLink?.let(Uri::parse) ?: Uri.Builder()
118
+ .scheme("glancekit")
119
+ .authority("progress")
120
+ .appendPath(widgetId.toString())
121
+ .build()
122
+
123
+ return Intent(Intent.ACTION_VIEW, deepLinkUri).apply {
124
+ `package` = context.packageName
125
+ addFlags(
126
+ Intent.FLAG_ACTIVITY_NEW_TASK or
127
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
128
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
129
+ )
130
+ }
131
+ }
132
+
133
+ companion object {
134
+ private const val TAG = "GlanceKitWidget"
135
+ }
136
+ }
137
+
138
+ private data class RenderState(
139
+ val widgetId: Int,
140
+ val data: ProgressCardData,
141
+ val onClick: Action?,
142
+ val mode: RenderMode,
143
+ )
144
+
145
+ private enum class RenderMode {
146
+ Fallback,
147
+ Data,
148
+ }
149
+
150
+ @Composable
151
+ private fun MinimalFallbackContent() {
152
+ Column(modifier = GlanceModifier.padding(CONTENT_PADDING)) {
153
+ Text(text = "GlanceKit")
154
+ Spacer(modifier = GlanceModifier.height(TEXT_SPACING_SMALL))
155
+ Text(text = "Waiting for data")
156
+ }
157
+ }
158
+
159
+ @Composable
160
+ private fun ProgressCardTextContent(
161
+ data: ProgressCardData,
162
+ onClick: Action?,
163
+ ) {
164
+ val contentModifier = GlanceModifier
165
+ .padding(CONTENT_PADDING)
166
+ .let { modifier ->
167
+ if (onClick == null) {
168
+ modifier
169
+ } else {
170
+ modifier.clickable(onClick)
171
+ }
172
+ }
173
+
174
+ Column(modifier = contentModifier) {
175
+ Text(text = data.title.ifBlank { ProgressCardData.DEFAULT.title })
176
+ Spacer(modifier = GlanceModifier.height(TEXT_SPACING_SMALL))
177
+ Text(text = data.subtitle.ifBlank { ProgressCardData.DEFAULT.subtitle })
178
+ Spacer(modifier = GlanceModifier.height(TEXT_SPACING_LARGE))
179
+ Text(text = "${data.progress.coerceIn(0, 100)}% complete")
180
+ }
181
+ }
182
+
183
+ private val CONTENT_PADDING = 16.dp
184
+ private val TEXT_SPACING_SMALL = 4.dp
185
+ private val TEXT_SPACING_LARGE = 10.dp
@@ -0,0 +1,52 @@
1
+ package dev.glancekit.androidcore.widget
2
+
3
+ import android.appwidget.AppWidgetManager
4
+ import android.content.Context
5
+ import android.content.Intent
6
+ import android.util.Log
7
+ import androidx.glance.appwidget.GlanceAppWidget
8
+ import androidx.glance.appwidget.GlanceAppWidgetReceiver
9
+ import dev.glancekit.androidcore.WidgetUpdateManager
10
+ import kotlinx.coroutines.runBlocking
11
+
12
+ open class ProgressCardWidgetReceiver : GlanceAppWidgetReceiver() {
13
+ override val glanceAppWidget: GlanceAppWidget = ProgressCardWidget()
14
+
15
+ override fun onReceive(context: Context, intent: Intent) {
16
+ Log.d(TAG, "onReceive action=${intent.action}")
17
+ super.onReceive(context, intent)
18
+ }
19
+
20
+ override fun onUpdate(
21
+ context: Context,
22
+ appWidgetManager: AppWidgetManager,
23
+ appWidgetIds: IntArray,
24
+ ) {
25
+ Log.d(TAG, "onUpdate widgetIds=${appWidgetIds.joinToString()}")
26
+ super.onUpdate(context, appWidgetManager, appWidgetIds)
27
+ }
28
+
29
+ override fun onEnabled(context: Context) {
30
+ Log.d(TAG, "onEnabled")
31
+ super.onEnabled(context)
32
+ }
33
+
34
+ override fun onDisabled(context: Context) {
35
+ Log.d(TAG, "onDisabled")
36
+ super.onDisabled(context)
37
+ }
38
+
39
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
40
+ Log.d(TAG, "onDeleted widgetIds=${appWidgetIds.joinToString()}")
41
+ super.onDeleted(context, appWidgetIds)
42
+ runBlocking {
43
+ appWidgetIds.forEach { widgetId ->
44
+ WidgetUpdateManager.deleteProgressCardWidget(context, widgetId)
45
+ }
46
+ }
47
+ }
48
+
49
+ companion object {
50
+ private const val TAG = "GlanceKitReceiver"
51
+ }
52
+ }