Skip to content
Merged
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
7 changes: 7 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ dependencies {
implementation(libs.coil3.network.ktor)

implementation(libs.glance.appwidget)
implementation(libs.androidx.remote.core)
implementation(libs.androidx.remote.creation)
implementation(libs.androidx.remote.creation.compose)
implementation(libs.androidx.remote.player.view)
implementation(libs.androidx.remote.player.core)
implementation(libs.androidx.remote.player.compose)
implementation(libs.androidx.wear.remote.material3)

implementation(libs.koin.core)
implementation(libs.koin.android)
Expand Down
20 changes: 19 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />

<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,androidx.wear.compose.remote.material3,androidx.wear.compose.material.core,androidx.wear.compose.foundation,androidx.wear.compose.material3"/>

<application
android:name=".PeopleInSpaceApplication"
android:allowBackup="true"
Expand Down Expand Up @@ -51,6 +54,21 @@
android:resource="@xml/iss_widget_info" />
</receiver>

<receiver
android:name="com.surrus.peopleinspace.remotecompose.PeopleInSpaceWidgetReceiver"
android:label="ISS Map"
android:enabled="@bool/remotecompose_appwidget_available"
android:exported="false"
tools:targetApi="36">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>

<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/rc_peopleinspace_info" />
</receiver>

</application>

</manifest>
69 changes: 69 additions & 0 deletions app/src/main/java/com/surrus/peopleinspace/glance/Fetch.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.surrus.peopleinspace.glance

import android.content.Context
import android.graphics.Bitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import dev.johnoreilly.common.remote.IssPosition
import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface
import dev.johnoreilly.peopleinspace.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.Projection
import org.osmdroid.views.drawing.MapSnapshot
import org.osmdroid.views.overlay.IconOverlay
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine


suspend fun fetchIssPosition(repository: PeopleInSpaceRepositoryInterface): GeoPoint {
val issPosition: IssPosition = repository.pollISSPosition().first()

val issPositionPoint = GeoPoint(issPosition.latitude, issPosition.longitude)
println("ISS Position: $issPositionPoint")
return issPositionPoint
}

suspend fun fetchMapBitmap(
issPositionPoint: GeoPoint,
context: Context,
includeStationMarker: Boolean = true,
zoomLevel: Double = 1.0,
pWidth: Int = 480,
pHeight: Int = 240,
): ImageBitmap {
val stationMarker = IconOverlay(
issPositionPoint,
context.resources.getDrawable(R.drawable.ic_iss, context.theme)
)

val source = TileSourceFactory.DEFAULT_TILE_SOURCE
val projection = Projection(zoomLevel, pWidth, pHeight, issPositionPoint, 0f, true, false, 0, 0)

val bitmap = withContext(Dispatchers.Main) {
suspendCoroutine<Bitmap> { cont ->
val mapSnapshot = MapSnapshot(
{
if (it.status == MapSnapshot.Status.CANVAS_OK) {
val bitmap = Bitmap.createBitmap(it.bitmap)
cont.resume(bitmap)
}
},
MapSnapshot.INCLUDE_FLAG_UPTODATE or MapSnapshot.INCLUDE_FLAG_SCALED,
MapTileProviderBasic(context, source, null),
if (includeStationMarker) listOf(stationMarker) else listOf(),
projection
)

launch(Dispatchers.IO) {
mapSnapshot.run()
}
}
}
return bitmap.asImageBitmap()
}
56 changes: 6 additions & 50 deletions app/src/main/java/com/surrus/peopleinspace/glance/ISSMapWidget.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package dev.johnoreilly.peopleinspace.glance

import android.content.Context
import android.graphics.Bitmap
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.glance.GlanceId
import androidx.glance.GlanceModifier
import androidx.glance.Image
Expand All @@ -14,64 +14,20 @@ import androidx.glance.appwidget.provideContent
import androidx.glance.background
import androidx.glance.layout.Box
import androidx.glance.layout.fillMaxSize
import dev.johnoreilly.common.remote.IssPosition
import com.surrus.peopleinspace.glance.fetchIssPosition
import com.surrus.peopleinspace.glance.fetchMapBitmap
import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface
import dev.johnoreilly.peopleinspace.MainActivity
import dev.johnoreilly.peopleinspace.R
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.osmdroid.tileprovider.MapTileProviderBasic
import org.osmdroid.tileprovider.tilesource.TileSourceFactory
import org.osmdroid.util.GeoPoint
import org.osmdroid.views.Projection
import org.osmdroid.views.drawing.MapSnapshot
import org.osmdroid.views.overlay.IconOverlay
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine

class ISSMapWidget: GlanceAppWidget(), KoinComponent {
private val repository: PeopleInSpaceRepositoryInterface by inject()

override suspend fun provideGlance(context: Context, id: GlanceId) {
val issPosition: IssPosition = withContext(Dispatchers.Main) {
repository.pollISSPosition().first()
}

val issPositionPoint = GeoPoint(issPosition.latitude, issPosition.longitude)
println("ISS Position: $issPositionPoint")

val stationMarker = IconOverlay(
issPositionPoint,
context.resources.getDrawable(R.drawable.ic_iss, context.theme)
)

val source = TileSourceFactory.DEFAULT_TILE_SOURCE
val projection = Projection(1.0, 480, 240, issPositionPoint, 0f, true, false, 0, 0)
val issPositionPoint = fetchIssPosition(repository)

val bitmap = withContext(Dispatchers.Main) {
suspendCoroutine<Bitmap> { cont ->
val mapSnapshot = MapSnapshot(
{
if (it.status == MapSnapshot.Status.CANVAS_OK) {
val bitmap = Bitmap.createBitmap(it.bitmap)
cont.resume(bitmap)
}
},
MapSnapshot.INCLUDE_FLAG_UPTODATE or MapSnapshot.INCLUDE_FLAG_SCALED,
MapTileProviderBasic(context, source, null),
listOf(stationMarker),
projection
)

launch(Dispatchers.IO) {
mapSnapshot.run()
}
}
}
val bitmap = fetchMapBitmap(issPositionPoint, context)

provideContent {
Box(
Expand All @@ -81,7 +37,7 @@ class ISSMapWidget: GlanceAppWidget(), KoinComponent {
) {
Image(
modifier = GlanceModifier.fillMaxSize(),
provider = ImageProvider(bitmap),
provider = ImageProvider(bitmap.asAndroidBitmap()),
contentDescription = "ISS Location"
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
@file:SuppressLint("RestrictedApi")

package com.surrus.peopleinspace.remotecompose

import android.annotation.SuppressLint
import android.graphics.BitmapFactory
import androidx.compose.remote.creation.compose.layout.RemoteBox
import androidx.compose.remote.creation.compose.layout.RemoteCanvas
import androidx.compose.remote.creation.compose.layout.RemoteComposable
import androidx.compose.remote.creation.compose.layout.RemoteOffset
import androidx.compose.remote.creation.compose.layout.rotate
import androidx.compose.remote.creation.compose.modifier.RemoteModifier
import androidx.compose.remote.creation.compose.modifier.background
import androidx.compose.remote.creation.compose.modifier.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.graphics.drawable.toBitmap
import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat
import com.surrus.peopleinspace.remotecompose.util.RemotePreview
import dev.johnoreilly.peopleinspace.R
import org.osmdroid.util.GeoPoint

@RemoteComposable
@Composable
fun PeopleInSpaceCard(map: ImageBitmap, issPosition: GeoPoint) {
val issVectorDrawable = issVectorDrawable()
RemoteBox(
modifier = RemoteModifier.fillMaxSize().background(Color.DarkGray)
) {
val bitmap = issVectorDrawable
.apply {
setTint(Color.Black.toArgb())
}
.toBitmap(256, 256)

RemoteCanvas(modifier = RemoteModifier.fillMaxSize()) {
val mapBitmapId = canvas.document.addBitmap(map.asAndroidBitmap())
canvas.document.drawBitmap(
mapBitmapId,
0f,
0f,
remote.component.width.id,
remote.component.height.id,
null
)

val centerX = remote.component.centerX
val centerY = remote.component.centerY
drawCircle(Color.White.copy(alpha = 0.3f), radius = 48f, RemoteOffset(centerX, centerY))
drawCircle(Color.Black, radius = 48f, RemoteOffset(centerX, centerY), 1f, Stroke(1f))

val issBitmapId = canvas.document.addBitmap(bitmap)
val angle = remote.time.ContinuousSec() * 10f % 360f
rotate(angle, centerX, centerY) {
canvas.document.drawBitmap(
issBitmapId,
(centerX - 32f).id,
(centerY - 32f).id,
(centerX + 32f).id,
(centerY + 32f).id,
null
)
}
}
}
}

@Composable
private fun issVectorDrawable(): VectorDrawableCompat {
val drawable = VectorDrawableCompat.create(
LocalResources.current, R.drawable.ic_iss,
LocalContext.current.theme
)!!
return drawable
}

@Composable
@Preview(widthDp = 200, heightDp = 100)
fun PeopleInSpaceCardPreview() {
RemotePreview {
val previewMap =
BitmapFactory.decodeResource(LocalResources.current, R.drawable.anfield).asImageBitmap()
PeopleInSpaceCard(previewMap, GeoPoint(0.0, 0.0))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package com.surrus.peopleinspace.remotecompose

import android.annotation.SuppressLint
import android.appwidget.AppWidgetManager
import android.content.Context
import android.os.Build
import android.widget.RemoteViews
import androidx.annotation.RequiresApi
import androidx.compose.remote.creation.profile.Profile
import androidx.compose.remote.creation.profile.RcPlatformProfiles
import androidx.compose.ui.graphics.ImageBitmap
import com.surrus.peopleinspace.glance.fetchIssPosition
import com.surrus.peopleinspace.glance.fetchMapBitmap
import com.surrus.peopleinspace.remotecompose.util.AsyncAppWidgetReceiver
import com.surrus.peopleinspace.remotecompose.util.RemoteComposeRecorder
import dev.johnoreilly.common.repository.PeopleInSpaceRepositoryInterface
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import okio.ByteString
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.osmdroid.util.GeoPoint

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
@SuppressLint("RestrictedApi")
class PeopleInSpaceWidgetReceiver : AsyncAppWidgetReceiver(), KoinComponent {
private val repository: PeopleInSpaceRepositoryInterface by inject()

/** Called when widgets must provide remote views. */

override suspend fun update(context: Context, wm: AppWidgetManager, widgetIds: IntArray) {
// receiver context is restricted
val appContext = context.applicationContext

val issPosition = fetchIssPosition(repository)

coroutineScope {
widgetIds.forEach { widgetId ->
launch {
val bitmap = fetchMapBitmap(
issPosition,
appContext,
includeStationMarker = false,
zoomLevel = 3.0,
pWidth = 400,
pHeight = 400
)

val bytes = recordPeopleInSpaceCard(
profile = RcPlatformProfiles.WIDGETS_V6,
recorder = RemoteComposeRecorder(appContext),
issPosition = issPosition,
map = bitmap
)

val widget = RemoteViews(DrawInstructions(bytes))

wm.updateAppWidget(widgetId, widget)
}
}
}
}

private fun DrawInstructions(bytes: ByteString): RemoteViews.DrawInstructions {
return RemoteViews.DrawInstructions.Builder(listOf(bytes.toByteArray())).build()
}

suspend fun recordPeopleInSpaceCard(
recorder: RemoteComposeRecorder,
profile: Profile,
issPosition: GeoPoint,
map: ImageBitmap,
): ByteString {
return recorder.record(profile) { PeopleInSpaceCard(map, issPosition) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.surrus.peopleinspace.remotecompose.util

import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context

abstract class AsyncAppWidgetReceiver : AppWidgetProvider() {
override fun onUpdate(context: Context, wm: AppWidgetManager, widgetIds: IntArray) {
goAsync {
update(context, wm, widgetIds)
}
}

abstract suspend fun update(context: Context, wm: AppWidgetManager, widgetIds: IntArray)
}
Loading
Loading