Skip to content

Commit fd62dd9

Browse files
committed
Previewable remote compose card
Build up something before exposing as a widget.
1 parent 75d7533 commit fd62dd9

File tree

10 files changed

+307
-1
lines changed

10 files changed

+307
-1
lines changed

app/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ dependencies {
8383
implementation(libs.coil3.network.ktor)
8484

8585
implementation(libs.glance.appwidget)
86+
implementation(libs.androidx.remote.core)
87+
implementation(libs.androidx.remote.creation)
88+
implementation(libs.androidx.remote.creation.compose)
89+
implementation(libs.androidx.remote.player.view)
90+
implementation(libs.androidx.remote.player.core)
91+
implementation(libs.androidx.remote.player.compose)
8692

8793
implementation(libs.koin.core)
8894
implementation(libs.koin.android)

app/src/main/AndroidManifest.xml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
<?xml version="1.0" encoding="utf-8"?>
2-
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+
xmlns:tools="http://schemas.android.com/tools">
34
<uses-permission android:name="android.permission.INTERNET" />
45

6+
<uses-sdk tools:overrideLibrary="androidx.compose.remote.creation,androidx.compose.remote.player.compose,androidx.compose.remote.player.core,androidx.compose.remote.player.view,androidx.compose.remote.creation.compose"/>
7+
58
<application
69
android:name=".PeopleInSpaceApplication"
710
android:allowBackup="true"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package com.surrus.peopleinspace.remotecompose
2+
3+
import android.annotation.SuppressLint
4+
import androidx.compose.remote.creation.compose.layout.RemoteBox
5+
import androidx.compose.remote.creation.compose.layout.RemoteComposable
6+
import androidx.compose.remote.creation.compose.layout.RemoteText
7+
import androidx.compose.remote.creation.compose.modifier.RemoteModifier
8+
import androidx.compose.remote.creation.compose.modifier.background
9+
import androidx.compose.remote.creation.compose.modifier.fillMaxSize
10+
import androidx.compose.remote.creation.compose.state.RemoteColor
11+
import androidx.compose.remote.creation.compose.state.RemoteString
12+
import androidx.compose.remote.creation.compose.state.rememberRemoteIntValue
13+
import androidx.compose.runtime.Composable
14+
import androidx.compose.ui.graphics.Color
15+
import androidx.compose.ui.tooling.preview.Preview
16+
import com.surrus.peopleinspace.remotecompose.util.RemotePreview
17+
18+
@SuppressLint("RestrictedApi")
19+
@RemoteComposable
20+
@Composable
21+
fun PeopleInSpaceCard() {
22+
RemoteBox(
23+
modifier = RemoteModifier.fillMaxSize().background(Color.DarkGray)
24+
) {
25+
// TODO real content
26+
val count = rememberRemoteIntValue { 5 }
27+
val text = RemoteString("People in Space: ") + count.toRemoteString(1, 0)
28+
RemoteText(text, color = RemoteColor(Color.White))
29+
}
30+
}
31+
32+
@Composable
33+
@Preview(widthDp = 200, heightDp = 100)
34+
fun PeopleInSpaceCardPreview() {
35+
RemotePreview {
36+
PeopleInSpaceCard()
37+
}
38+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package com.surrus.peopleinspace.remotecompose
2+
3+
import android.annotation.SuppressLint
4+
import android.appwidget.AppWidgetManager
5+
import android.content.Context
6+
import android.os.Build
7+
import android.widget.RemoteViews
8+
import androidx.annotation.RequiresApi
9+
import androidx.compose.remote.creation.profile.Profile
10+
import androidx.compose.remote.creation.profile.RcPlatformProfiles
11+
import com.surrus.peopleinspace.remotecompose.util.AsyncAppWidgetReceiver
12+
import com.surrus.peopleinspace.remotecompose.util.RemoteComposeRecorder
13+
import okio.ByteString
14+
15+
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
16+
@SuppressLint("RestrictedApi")
17+
class RCMemberCardWidgetReceiver : AsyncAppWidgetReceiver() {
18+
/** Called when widgets must provide remote views. */
19+
20+
override suspend fun update(context: Context, wm: AppWidgetManager, widgetIds: IntArray) {
21+
22+
val bytes = recordPeopleInSpaceCard(
23+
profile = RcPlatformProfiles.WIDGETS_V6,
24+
recorder = RemoteComposeRecorder(context)
25+
)
26+
27+
val widget =
28+
RemoteViews(RemoteViews.DrawInstructions.Builder(listOf(bytes.toByteArray())).build())
29+
30+
widgetIds.forEach { widgetId -> wm.updateAppWidget(widgetId, widget) }
31+
}
32+
33+
suspend fun recordPeopleInSpaceCard(
34+
recorder: RemoteComposeRecorder,
35+
profile: Profile
36+
): ByteString {
37+
return recorder.record(profile) { PeopleInSpaceCard() }
38+
}
39+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.surrus.peopleinspace.remotecompose.util
2+
3+
import android.appwidget.AppWidgetManager
4+
import android.appwidget.AppWidgetProvider
5+
import android.content.Context
6+
7+
abstract class AsyncAppWidgetReceiver : AppWidgetProvider() {
8+
override fun onUpdate(context: Context, wm: AppWidgetManager, widgetIds: IntArray) {
9+
goAsync {
10+
update(context, wm, widgetIds)
11+
}
12+
}
13+
14+
abstract suspend fun update(context: Context, wm: AppWidgetManager, widgetIds: IntArray)
15+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.surrus.peopleinspace.remotecompose.util
2+
3+
import android.annotation.SuppressLint
4+
import android.content.Context
5+
import androidx.compose.remote.creation.compose.capture.CreationDisplayInfo
6+
import androidx.compose.remote.creation.compose.capture.RemoteComposeCapture
7+
import androidx.compose.remote.creation.compose.layout.RemoteComposable
8+
import androidx.compose.remote.creation.profile.Profile
9+
import androidx.compose.remote.creation.profile.RcPlatformProfiles
10+
import androidx.compose.runtime.Composable
11+
import kotlinx.coroutines.Dispatchers
12+
import kotlinx.coroutines.suspendCancellableCoroutine
13+
import kotlinx.coroutines.withContext
14+
import okio.ByteString
15+
import okio.ByteString.Companion.toByteString
16+
17+
class RemoteComposeRecorder(private val context: Context) {
18+
@SuppressLint("RestrictedApi")
19+
suspend fun record(
20+
profile: Profile = RcPlatformProfiles.ANDROIDX,
21+
content: @RemoteComposable @Composable () -> Unit
22+
): ByteString =
23+
withContext(Dispatchers.Main) {
24+
suspendCancellableCoroutine { continuation ->
25+
val connection = CreationDisplayInfo()
26+
RemoteComposeCapture(
27+
context = context,
28+
creationDisplayInfo = connection,
29+
immediateCapture = true,
30+
onPaint = { view, writer ->
31+
val rcDocBytes = writer.encodeToByteArray().toByteString()
32+
if (continuation.isActive) {
33+
continuation.resume(
34+
rcDocBytes, { _, _, _ ->
35+
println("Cancelled during execution")
36+
}
37+
)
38+
}
39+
true
40+
},
41+
onCaptureReady = @Composable {},
42+
profile = profile,
43+
content = content,
44+
)
45+
continuation.invokeOnCancellation {
46+
println("Cancellation")
47+
}
48+
}
49+
}
50+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright (C) 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.surrus.peopleinspace.remotecompose.util
17+
18+
import android.annotation.SuppressLint
19+
import androidx.compose.foundation.layout.Box
20+
import androidx.compose.foundation.layout.fillMaxSize
21+
import androidx.compose.remote.creation.compose.capture.RememberRemoteDocumentInline
22+
import androidx.compose.remote.creation.compose.layout.RemoteComposable
23+
import androidx.compose.remote.player.compose.RemoteDocumentPlayer
24+
import androidx.compose.remote.player.core.RemoteDocument
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.runtime.getValue
27+
import androidx.compose.runtime.mutableStateOf
28+
import androidx.compose.runtime.remember
29+
import androidx.compose.runtime.setValue
30+
import androidx.compose.ui.Modifier
31+
import androidx.compose.ui.platform.LocalWindowInfo
32+
33+
/**
34+
* Display a RemoteCompose Composable in the Android Studio Preview.
35+
*
36+
* Currently only works in single Preview mode, where previews presumably run longer.
37+
*/
38+
@SuppressLint("RestrictedApi")
39+
@Composable
40+
fun RemotePreview(
41+
modifier: Modifier = Modifier,
42+
content: @RemoteComposable @Composable () -> Unit
43+
) {
44+
var documentState by remember { mutableStateOf<RemoteDocument?>(null) }
45+
46+
Box(modifier = modifier.fillMaxSize()) {
47+
RememberRemoteDocumentInline(
48+
onDocument = { doc ->
49+
println("Document generated: $doc")
50+
if (documentState == null) {
51+
// Generate seems to get called again with a partial document
52+
// Essentially re-recording but with existing state, so document is incomplete
53+
documentState = RemoteDocument(doc)
54+
}
55+
}
56+
) {
57+
content()
58+
}
59+
60+
if (documentState != null) {
61+
val windowInfo = LocalWindowInfo.current
62+
RemoteDocumentPlayer(
63+
document = documentState!!.document,
64+
windowInfo.containerSize.width,
65+
windowInfo.containerSize.height,
66+
modifier = Modifier.fillMaxSize(),
67+
debugMode = 0,
68+
)
69+
}
70+
}
71+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/*
2+
* Copyright 2021 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* From https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:glance/glance-appwidget/src/main/java/androidx/glance/appwidget/CoroutineBroadcastReceiver.kt
17+
*/
18+
package com.surrus.peopleinspace.remotecompose.util
19+
20+
import android.content.BroadcastReceiver
21+
import kotlinx.coroutines.CoroutineScope
22+
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.cancel
24+
import kotlinx.coroutines.coroutineScope
25+
import kotlinx.coroutines.launch
26+
import kotlin.coroutines.CoroutineContext
27+
import kotlin.coroutines.cancellation.CancellationException
28+
29+
/**
30+
* Execute the block asynchronously in a scope with the lifetime of the broadcast.
31+
*
32+
* The coroutine scope will finish once the block return, as the broadcast will finish at that point
33+
* too, allowing the system to kill the broadcast.
34+
*/
35+
internal fun BroadcastReceiver.goAsync(
36+
coroutineContext: CoroutineContext = Dispatchers.Default,
37+
block: suspend CoroutineScope.() -> Unit,
38+
) {
39+
val parentScope = CoroutineScope(coroutineContext)
40+
val pendingResult = goAsync()
41+
42+
parentScope.launch {
43+
try {
44+
try {
45+
// Use `coroutineScope` so that errors within `block` are rethrown at the end of
46+
// this scope, instead of propagating up the Job hierarchy. If we use `parentScope`
47+
// directly, then errors in child jobs `launch`ed by `block` would trigger the
48+
// CoroutineExceptionHandler and crash the process.
49+
coroutineScope { this.block() }
50+
} catch (e: Throwable) {
51+
if (e is CancellationException && e.cause == null) {
52+
// Regular cancellation, do nothing. The scope will always be cancelled below.
53+
} else {
54+
println("BroadcastReceiver execution failed $e")
55+
}
56+
} finally {
57+
// Make sure the parent scope is cancelled in all cases. Nothing can be in the
58+
// `finally` block after this, as this throws a `CancellationException`.
59+
parentScope.cancel()
60+
}
61+
} finally {
62+
// Notify ActivityManager that we are finished with this broadcast. This must be the
63+
// last call, as the process may be killed after calling this.
64+
try {
65+
pendingResult.finish()
66+
} catch (e: IllegalStateException) {
67+
// On some OEM devices, this may throw an error about "Broadcast already finished".
68+
// See b/257513022.
69+
println("Error thrown when trying to finish broadcast $e")
70+
}
71+
}
72+
}
73+
}

gradle/libs.versions.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ slf4j = "2.0.17"
1818
sqlDelight = "2.1.0"
1919
sqlJs = "1.8.0"
2020
webPackPlugin = "9.1.0"
21+
remoteCompose = "1.0.0-SNAPSHOT"
2122

2223

2324
androidxActivity = "1.10.1"
@@ -155,6 +156,13 @@ androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-
155156

156157
mcp-kotlin = { group = "io.modelcontextprotocol", name = "kotlin-sdk", version.ref = "mcp" }
157158

159+
androidx-remote-core = { module = "androidx.compose.remote:remote-core", version.ref = "remoteCompose" }
160+
androidx-remote-creation = { module = "androidx.compose.remote:remote-creation", version.ref = "remoteCompose" }
161+
androidx-remote-creation-compose = { module = "androidx.compose.remote:remote-creation-compose", version.ref = "remoteCompose" }
162+
androidx-remote-player-view = { module = "androidx.compose.remote:remote-player-view", version.ref = "remoteCompose" }
163+
androidx-remote-player-core = { module = "androidx.compose.remote:remote-player-core", version.ref = "remoteCompose" }
164+
androidx-remote-player-compose = { module = "androidx.compose.remote:remote-player-compose", version.ref = "remoteCompose" }
165+
158166
[bundles]
159167
ktor-common = ["ktor-client-core", "ktor-client-json", "ktor-client-logging", "ktor-client-serialization", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json"]
160168

settings.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ pluginManagement {
44
google()
55
mavenCentral()
66
gradlePluginPortal()
7+
maven {
8+
url = uri("https://androidx.dev/snapshots/builds/14400821/artifacts/repository")
9+
}
710
}
811
}
912

0 commit comments

Comments
 (0)