Skip to content

Commit

Permalink
RSDK-7137 UX fixes (#3)
Browse files Browse the repository at this point in the history
* status view, hard restart for clean down/up

* more states, normal size for progress spinner

* more or less working tab view why is this not built in

* better emoji, use tablayout for config section

* try-with-resource

* checkpoint to roll back for boot failure

* confirm read perm, add default comment

* persist json comment
  • Loading branch information
abe-winter authored Apr 4, 2024
1 parent 9f63164 commit 2f87504
Show file tree
Hide file tree
Showing 6 changed files with 267 additions and 61 deletions.
2 changes: 2 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ android {
dependencies {
implementation("androidx.core:core-ktx:1.10.1")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("androidx.compose.runtime:runtime-livedata:1.6.4")
implementation("com.google.android.material:material:1.9.0")
implementation("com.jakewharton:process-phoenix:3.0.0")
implementation(files("${(System.getenv("RDK_PATH") ?: "").ifEmpty {"/usr/local/src/rdk"} }/droid-rdk.aar"))
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
implementation("androidx.activity:activity-compose:1.8.2")
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- not on API 28: <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" /> -->

<application
android:allowBackup="true"
Expand Down
126 changes: 82 additions & 44 deletions app/src/main/java/com/viam/rdk/fgservice/MyScaffold.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,37 @@ package com.viam.rdk.fgservice

import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowForward
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material.icons.outlined.Clear
import androidx.compose.material.icons.outlined.PlayArrow
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
Expand All @@ -32,8 +45,29 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp

@Composable
fun StateViewer(status: RDKStatus) {
Row(horizontalArrangement = Arrangement.Start, modifier = Modifier
.fillMaxWidth()
.height(30.dp)) {
when (status) {
RDKStatus.STOPPING -> CircularProgressIndicator(modifier = Modifier
.height(20.dp)
.width(20.dp))
RDKStatus.RUNNING -> Text("\uD83C\uDD99")
RDKStatus.WAIT_PERMISSION -> Text("\uD83D\uDD12")
RDKStatus.WAIT_CONFIG -> Text("⏳⚙\uFE0F")
RDKStatus.STOPPED -> Text("\uD83D\uDED1")
RDKStatus.UNSET -> Text("")
}
Spacer(Modifier.width(10.dp))
Text("service state ${status.name}", modifier = Modifier.weight(1f))
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MyScaffold(activity: RDKLaunch) {
Expand All @@ -46,6 +80,9 @@ fun MyScaffold(activity: RDKLaunch) {
var secretValue by rememberSaveable() {
mutableStateOf("")
}
val bgState by rememberSaveable {
activity.bgState
}
val mono = TextStyle.Default.copy(fontFamily = FontFamily.Monospace)
val textMod = Modifier
.border(1.dp, Color.Black)
Expand All @@ -57,13 +94,9 @@ fun MyScaffold(activity: RDKLaunch) {
TopAppBar(
title = { Text("Viam RDK") },
actions = {
// todo: confirmation dialog for restart button
IconButton(onClick = { maybeStart(activity) }) {
Icon(Icons.Outlined.PlayArrow, "Start")
}
IconButton(onClick = { maybeStop(activity) }) {
Icon(Icons.Outlined.Clear, "Stop")
}
OutlinedButton(onClick = { activity.hardRestart() }, enabled = bgState.restartable()) { Text("Restart") }
Spacer(Modifier.width(20.dp))
OutlinedButton(onClick = { singleton?.stopAndDestroy() }, enabled = bgState.stoppable()) { Text("Stop") }
},
)
}
Expand All @@ -73,53 +106,58 @@ fun MyScaffold(activity: RDKLaunch) {
.padding(PaddingValues(horizontal = 10.dp))
.verticalScroll(rememberScrollState())
) {
StateViewer(bgState)
PermissionsCard(activity)

Spacer(Modifier.height(20.dp))

Text("viam.json path", style=MaterialTheme.typography.titleMedium)
Text(activity.confPath.value)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = activity::openFile) {
Text("Load viam.json")
}
Button(onClick = { activity.savePref(defaultConfPath) }){
Text("Default viam.json")
}
}

Text("Using config ${jsonComment.value}", color = Color.Gray, maxLines = 3)
Spacer(Modifier.height(20.dp))

Text("ID and secret", style=MaterialTheme.typography.titleMedium)
Text("ID", style=MaterialTheme.typography.titleSmall)
BasicTextField(value = idValue, onValueChange = {idValue = it}, textStyle=mono, modifier = textMod)
Text("secret", style=MaterialTheme.typography.titleSmall)
BasicTextField(value = secretValue, onValueChange = {secretValue=it}, textStyle=mono, modifier = textMod)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = { activity.setIdKeyConfig(idValue, secretValue) }) {
Text("Apply key + secret")
}
Button(onClick = { idValue = ""; secretValue = "" }) {
Text("Clear")
TabLayout(listOf("Load json", "ID + secret", "Paste json")) {
Column {
Button(onClick = { activity.savePref(defaultConfPath); activity.setJsonComment(jsonComments["default"]!!) }){
Text("Use default /sdcard/Download/viam.json")
}
Button(onClick = activity::openFile) {
Text("Browse for viam.json")
}
}
}

Spacer(Modifier.height(20.dp))

Text("Paste full json", style=MaterialTheme.typography.titleMedium)
BasicTextField(
value=fullJson,
onValueChange={fullJson = it},
textStyle = mono,
minLines = 3,
modifier = textMod,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = { activity.setPastedConfig(fullJson) }) {
Text("Apply pasted config")
Column {
Text("ID and secret", style=MaterialTheme.typography.titleMedium)
Text("ID", style=MaterialTheme.typography.titleSmall)
BasicTextField(value = idValue, onValueChange = {idValue = it}, textStyle=mono, modifier = textMod)
Text("secret", style=MaterialTheme.typography.titleSmall)
BasicTextField(value = secretValue, onValueChange = {secretValue=it}, textStyle=mono, modifier = textMod)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = { activity.setIdKeyConfig(idValue, secretValue) }) {
Text("Apply key + secret")
}
Button(onClick = { idValue = ""; secretValue = "" }) {
Text("Clear")
}
}
}
Button(onClick = { fullJson = "" }) {
Text("Clear")

Column {
Text("Paste full json", style=MaterialTheme.typography.titleMedium)
BasicTextField(
value=fullJson,
onValueChange={fullJson = it},
textStyle = mono,
minLines = 3,
modifier = textMod,
)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Button(onClick = { activity.setPastedConfig(fullJson) }) {
Text("Apply pasted config")
}
Button(onClick = { fullJson = "" }) {
Text("Clear")
}
}
}
}
}
Expand Down
69 changes: 60 additions & 9 deletions app/src/main/java/com/viam/rdk/fgservice/RDKForegroundService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import droid.Droid.mainEntry
import droid.Droid.droidStopHook
import java.io.File
import java.nio.file.StandardWatchEventKinds
import java.util.Timer
import java.util.TimerTask
import kotlin.io.path.exists


Expand All @@ -31,11 +33,24 @@ fun missingPerms(context: Context, perms: Array<String>): Array<String> {
}).toTypedArray()
}

enum class RDKStatus {
STOPPED, WAIT_CONFIG, WAIT_PERMISSION, RUNNING, STOPPING, UNSET;

fun restartable(): Boolean {
return this == STOPPING || this == STOPPED
}

fun stoppable(): Boolean {
return this == RUNNING || this == WAIT_CONFIG || this == WAIT_PERMISSION
}
}

class RDKThread() : Thread() {
lateinit var filesDir: java.io.File
lateinit var context: Context
lateinit var confPath: String
var waitPerms: Boolean = true
var status: RDKStatus = RDKStatus.STOPPED

/** wait for necessary permissions to be granted */
fun permissionLoop() {
Expand All @@ -58,45 +73,60 @@ class RDKThread() : Thread() {

override fun run() {
super.run()
status = RDKStatus.WAIT_PERMISSION
if (waitPerms) {
permissionLoop()
} else {
Log.i(TAG, "waitPerms = false, skipping permissionLoop")
}
status = RDKStatus.WAIT_CONFIG
val path = File(confPath)
val dirPath = path.parentFile?.toPath()
if (dirPath == null) {
Log.i(TAG, "confPath $confPath parentFile is null")
return
}
val watcher = dirPath.fileSystem.newWatchService()
while (!path.exists()) {
Log.i(TAG, "waiting for viam.json at $path")
dirPath.register(watcher, arrayOf(StandardWatchEventKinds.ENTRY_CREATE))
watcher.take()
dirPath.fileSystem.newWatchService().use {
while (!path.exists()) {
Log.i(TAG, "waiting for viam.json at $path")
dirPath.register(it, arrayOf(StandardWatchEventKinds.ENTRY_CREATE))
it.take()
}
}
watcher.close()
Log.i(TAG, "found $path")
if (!path.canRead()) {
Log.e(TAG, "can't read path at $path, bailing")
// todo: communicate this in UX as state
return
}
// todo: I think we crash the entire process if the viam.json config fails to parse; be more graceful
try {
status = RDKStatus.RUNNING
mainEntry(path.toString(), filesDir.toString())
} catch (e: Exception) {
Log.e(TAG, "viam thread caught error $e")
} finally {
Log.i(TAG, "finished viam thread")
}
status = RDKStatus.STOPPED
}
}

class RDKBinder : Binder() {}

var singleton: RDKForegroundService? = null

class RDKForegroundService : Service() {
private final val thread = RDKThread()
final val thread = RDKThread()
var timer: Timer? = null

override fun onBind(intent: Intent): IBinder {
return RDKBinder()
}

override fun onCreate() {
super.onCreate()
singleton = this
val chan = NotificationChannel("background", "background", NotificationManager.IMPORTANCE_HIGH)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager).createNotificationChannel(chan)
val notif = Notification.Builder(this, chan.id).setContentText("The RDK is running in the background").setSmallIcon(R.mipmap.ic_launcher).build()
Expand All @@ -107,16 +137,37 @@ class RDKForegroundService : Service() {
thread.filesDir = cacheDir
thread.context = applicationContext
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
// todo: can just set these values directly, don't need to do through prefs
thread.confPath = prefs.getString("confPath", defaultConfPath) ?: defaultConfPath
thread.waitPerms = prefs.getBoolean("waitPerms", true)
Log.i(TAG, "got confPath ${thread.confPath}")
thread.start()
return super.onStartCommand(intent, flags, startId)
}

// trigger RDK stop, destroy this service when done
fun stopAndDestroy() {
thread.status = RDKStatus.STOPPING
droidStopHook()
if (timer == null) {
timer = Timer()
timer?.schedule(StopTimer(), 0, 250)
}
}

inner class StopTimer : TimerTask() {
override fun run() {
if (thread.status == RDKStatus.STOPPED) {
Log.i(TAG, "StopTimer found STOPPED")
cancel()
stopSelf()
}
}
}

override fun onDestroy() {
// todo: figure out how to stop thread -- need to send exit command to RDK via exported API
super.onDestroy()
droidStopHook()
singleton = null
Log.i(TAG, "service destroyed")
}
}
Loading

0 comments on commit 2f87504

Please sign in to comment.