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
5 changes: 3 additions & 2 deletions examples/ui-demo/lighttool.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ id = "com.thelightphone.uidemo"
label = "Light UI Demo"
versionCode = 1
versionName = "1.0.0"
permissions = ["android.permission.CAMERA"]
serverPackage = "com.lightos"
permissions = ["android.permission.CAMERA", "android.permission.ACCESS_FINE_LOCATION"]
#serverPackage = "com.lightos"
serverPackage = "com.thelightphone.sdk.emulator"
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,13 @@ class UiDemoHomeScreen(sealedActivity: SealedLightActivity) :
.lightClickable { navigateTo(::UiDemoKeyEventsScreen) }
.padding(vertical = 0.75f.gridUnitsAsDp()),
)
LightText(
text = "LOCATION",
variant = LightTextVariant.Copy,
modifier = Modifier
.lightClickable { navigateTo(::UiDemoLocationScreen) }
.padding(vertical = 0.75f.gridUnitsAsDp()),
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
package com.thelightphone.uidemo

import android.Manifest
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.thelightphone.sdk.SealedLightActivity
import com.thelightphone.sdk.SimpleLightScreen
import com.thelightphone.sdk.callRemoteServiceMethod
import com.thelightphone.sdk.checkPermission
import com.thelightphone.sdk.rememberPermissionRequestLauncher
import com.thelightphone.sdk.shared.LightServiceMethod
import com.thelightphone.sdk.shared.asKotlinResult
import com.thelightphone.sdk.shared.getOrNull
import com.thelightphone.sdk.ui.LightBarButton
import com.thelightphone.sdk.ui.LightIcons
import com.thelightphone.sdk.ui.LightText
import com.thelightphone.sdk.ui.LightTextVariant
import com.thelightphone.sdk.ui.LightTheme
import com.thelightphone.sdk.ui.LightThemeController
import com.thelightphone.sdk.ui.LightTopBar
import com.thelightphone.sdk.ui.LightTopBarCenter
import com.thelightphone.sdk.ui.gridUnitsAsDp
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlin.time.Clock
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant

class UiDemoLocationScreen(sealedLightActivity: SealedLightActivity) :
SimpleLightScreen<Unit>(sealedLightActivity) {
private var resumeCount by mutableIntStateOf(0)

override fun willShow() {
resumeCount++
}

@Composable
override fun Content() {
var hasLocationPermission by remember { mutableStateOf(false) }
var location by remember { mutableStateOf<LightOSLocation?>(null) }
val permissionLauncher = rememberPermissionRequestLauncher(Manifest.permission.ACCESS_FINE_LOCATION)

LaunchedEffect(resumeCount) {
val granted = checkPermission(Manifest.permission.ACCESS_FINE_LOCATION).asKotlinResult
.map { it.permissionResult == LightServiceMethod.GetPermission.Result.Granted }
.getOrDefault(false)
hasLocationPermission = granted
if (!granted) {
permissionLauncher?.launch()
return@LaunchedEffect
}

coroutineScope {
val requestUpdatesJob = launch {
while (isActive) {
callRemoteServiceMethod(LightServiceMethod.RequestLocationUpdates, Unit)
delay(30.seconds)
}
}
try {
while (isActive) {
val current =
callRemoteServiceMethod(LightServiceMethod.GetCurrentLocation, Unit)
.getOrNull()
location = current
?.takeIf {
it.latitude != null && it.longitude != null &&
it.accuracyMeters != null && it.timestampMs != null
}
?.let {
LightOSLocation(
it.latitude!!,
it.longitude!!,
it.accuracyMeters!!,
Instant.fromEpochMilliseconds(it.timestampMs!!),
)
}
delay(2.seconds)
}
} finally {
requestUpdatesJob.cancel()
withContext(NonCancellable) {
callRemoteServiceMethod(LightServiceMethod.ReleaseLocationUpdates, Unit)
}
}
}
}

LocationScreen(
hasLocationPermission = hasLocationPermission,
location = location,
onBack = { goBack() },
)
}
}

data class LightOSLocation(
val latitude: Double,
val longitude: Double,
val accuracyMeters: Double,
val timestamp: Instant,
)

@Composable
fun LocationScreen(
hasLocationPermission: Boolean,
location: LightOSLocation?,
onBack: () -> Unit
) {
val themeColors by LightThemeController.colors.collectAsState()
LightTheme(themeColors) {
Column(modifier = Modifier.fillMaxSize()) {
LightTopBar(
leftButton = LightBarButton.LightIcon(
icon = LightIcons.BACK,
onClick = onBack,
),
center = LightTopBarCenter.Text("Location"),
modifier = Modifier.padding(bottom = 1f.gridUnitsAsDp()),
)
Box(Modifier.fillMaxWidth().weight(1f)) {
@Composable fun CopyText(copy: String) {
LightText(
copy,
LightTextVariant.Copy,
align = TextAlign.Center,
modifier = Modifier.padding(2f.gridUnitsAsDp()).align(Alignment.Center)
)
}
if (!hasLocationPermission) {
CopyText("Location permission required for this screen")
} else if (location == null) {
CopyText("No location found yet...")
} else {
LocationDisplay(location)
}
}
}
}
}

@Composable
fun LocationDisplay(location: LightOSLocation) {
Column(Modifier.fillMaxSize().padding(1f.gridUnitsAsDp())) {
LightText(
"LATITUDE",
LightTextVariant.Superfine,
align = TextAlign.Center,
modifier = Modifier.padding(bottom = 3.dp)
)
LightText(
location.latitude.toString(),
LightTextVariant.Copy,
align = TextAlign.Center
)
Spacer(Modifier.height(1f.gridUnitsAsDp()))
LightText(
"LONGITUDE",
LightTextVariant.Superfine,
align = TextAlign.Center,
modifier = Modifier.padding(bottom = 3.dp)
)
LightText(
location.longitude.toString(),
LightTextVariant.Copy,
align = TextAlign.Center
)
Spacer(Modifier.height(1f.gridUnitsAsDp()))
LightText(
"ACCURACY",
LightTextVariant.Superfine,
align = TextAlign.Center,
modifier = Modifier.padding(bottom = 3.dp)
)
LightText(
"${location.accuracyMeters}m",
LightTextVariant.Copy,
align = TextAlign.Center
)
Spacer(Modifier.height(1f.gridUnitsAsDp()))
LightText(
"TIMESTAMP",
LightTextVariant.Superfine,
align = TextAlign.Center,
modifier = Modifier.padding(bottom = 3.dp)
)
LightText(
location.timestamp.toString(),
LightTextVariant.Copy,
align = TextAlign.Center
)
Spacer(Modifier.height(1f.gridUnitsAsDp()))
}
}

@Preview(widthDp = 1080 / 3, heightDp = 1240 / 3, showBackground = true)
@Composable
fun LocationScreenPreview() {
LocationScreen(true, LightOSLocation(0.0, 0.5, 12.0, Clock.System.now())) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,16 @@ object LightToolPolicy {
"android.permission.ACCESS_COARSE_LOCATION" to listOf("android.hardware.location.network"),
"android.permission.NFC" to listOf("android.hardware.nfc"),
)

/**
* Permissions that pull in another permission's `<uses-permission>` element.
* Android/Play Store lint (`CoarseFineLocation`) flags requesting FINE
* without also requesting COARSE, so we emit both any time a tool declares
* just FINE rather than making every tool remember to list both.
*/
val PERMISSION_IMPLIED_PERMISSIONS: Map<String, List<String>> = mapOf(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

"android.permission.ACCESS_FINE_LOCATION" to listOf("android.permission.ACCESS_COARSE_LOCATION"),
)
}

// --- TomlTable extension helpers --------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ object ManifestGenerator {
appendLine("""<manifest xmlns:android="http://schemas.android.com/apk/res/android">""")
// A capability declares what the tool does and the permissions it needs
// follow from that, so they are unioned in here rather than written by the tool.
// Some bare permissions likewise imply another one (e.g. FINE location
// implies COARSE, to keep lint quiet), so those are unioned in too.
val permissions = (
metadata.permissions + metadata.capabilities.flatMap {
LightToolPolicy.CAPABILITY_IMPLIED_PERMISSIONS[it].orEmpty()
}
metadata.permissions +
metadata.permissions.flatMap {
LightToolPolicy.PERMISSION_IMPLIED_PERMISSIONS[it].orEmpty()
} +
metadata.capabilities.flatMap {
LightToolPolicy.CAPABILITY_IMPLIED_PERMISSIONS[it].orEmpty()
}
).distinct()
for (perm in permissions) {
appendLine(""" <uses-permission android:name="${xmlAttr(perm)}" />""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ class ManifestGeneratorTest {
)
}

@Test
fun `fine location permission also emits coarse location permission`() {
// CoarseFineLocation lint wants COARSE declared alongside FINE; devs
// shouldn't have to remember to list both themselves.
val xml = render(permissions = listOf("android.permission.ACCESS_FINE_LOCATION"))
assertTrue(xml.contains("""<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />"""))
assertTrue(xml.contains("""<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />"""))
}

@Test
fun `explicit fine and coarse location permissions are not duplicated`() {
val xml = render(permissions = listOf(
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION",
))
assertTrue(xml.split("android.permission.ACCESS_COARSE_LOCATION").size - 1 == 1)
}

@Test
fun `coarse location alone does not imply fine location`() {
val xml = render(permissions = listOf("android.permission.ACCESS_COARSE_LOCATION"))
assertFalse(xml.contains("android.permission.ACCESS_FINE_LOCATION"))
}

@Test
fun `permission without implied feature emits no uses-feature`() {
val xml = render(permissions = listOf("android.permission.INTERNET"))
Expand Down
Loading
Loading