Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WorldScaleSceneView: Handle permissions and implement world tracking #709

Conversation

hud10837
Copy link
Collaborator

@hud10837 hud10837 commented Jan 20, 2025

Related to issue: https://devtopia.esri.com/runtime/kotlin/issues/5175

Description:

Adds world tracking and fixes the sequence of initialization, including handling multiple permissions, in the WorldScaleSceneView

Summary of changes:

  • Wraps the LocationDataSource in a new wrapper class to ensure it responds to lifecycle events
  • Requests location permissions automatically and fixes issues related to permission requests
  • Removes the heading change listener -- Swift wasn't doing this and I don't think it was necessary
  • Filters out locations based on the Swift model and resets the camera if it deviates from the LDS position more than 2 meters.

Pre-merge Checklist

@hud10837 hud10837 changed the title Hud10837/implement world tracking WorldScaleSceneView: Handle permissions Jan 21, 2025
@hud10837 hud10837 changed the title WorldScaleSceneView: Handle permissions WorldScaleSceneView: Handle permissions and implement world tracking Jan 21, 2025
@@ -39,7 +39,6 @@ fun MainScreen() {
}
WorldScaleSceneView(
arcGISScene = arcGISScene,
locationDataSource = SystemLocationDataSource(),
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This will no longer be passed in by the user

launch {
locationDataSource.status.collect {
var isLocationDataSourceStarted by remember { mutableStateOf(false) }
if (hasCompletedPermissionRequest && context.checkPermissionsGranted()) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

context.checkPermissionsGranted() will re-check the permissions with the system every time it is called, rather than storing that in a mutable state. I found this was much more predictable and easy to understand than using state, and it should only run once when hasCompletedPermissionRequest is true

Copy link
Collaborator

Choose a reason for hiding this comment

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

When hasCompletedPermissionRequest is true, doesn't this mean that all required permissions have been granted? Why do we need to then check again with checkPermissionsGranted()?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

In the version you reviewed, hasCompletedPermissionRequest could be true even if all permissions were not granted. However given your question I thought about it more and reworked it so that the mutable state encodes whether all permissions were granted.

}

Box(modifier = modifier) {
if (cameraPermissionGranted && arCoreInstalled) {
if (isLocationDataSourceStarted && arCoreInstalled) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

isLocationDataSourceStarted will only be true if permissions have already been granted

Comment on lines 364 to 374
hasCompletedRequest.value = true
}

if (!hasLaunchedRequest) {
SideEffect {
hasLaunchedRequest = true
launcher.launch(permissionsToRequest.toTypedArray())
}
}

return hasCompletedRequest
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note that hasLaunchedRequest is used internally to this function to ensure the request is only launched once. hasCompletedRequest is used for the WorldScaleSceneView to know it's okay to continue with the startup sequence

@hud10837 hud10837 marked this pull request as ready for review January 21, 2025 09:24
@01smito01 01smito01 self-requested a review January 21, 2025 12:27
@01smito01 01smito01 self-assigned this Jan 21, 2025
Copy link
Collaborator

@01smito01 01smito01 left a comment

Choose a reason for hiding this comment

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

Nothing obviously wrong to me, only a few queries & suggestions

requestCameraPermissionAutomatically: Boolean,
requestLocationPermissionAutomatically: Boolean,
initializationStatus: MutableState<WorldScaleSceneViewStatus>,
onInitializationStatusChanged: ((WorldScaleSceneViewStatus) -> Unit)?
Copy link
Collaborator

Choose a reason for hiding this comment

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

What's the thinking behind this (and ultimately MutableState<T>.update from ARHelpers) being nullable rather than defaulting to {}?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

These are both meant to be set to the callback parameter that's set on the WorldScaleSceneView, and the convention we've used there for the GeoViews and TableTopSceneView is to make them nullable. IIRC this was because by making them nullable we can easily check if they are set to something meaningful, and if not, we can avoid doing calculation (like listening to the state flows of the underlying geoview). I'm not sure how big an improvement that was but given the convention it makes sense to keep it going. In the case of these utility functions, I think it's easier to read the calling code if it just passes through the value from the WSSV argument, rather than having to do a null check inline

val oldLocationFarFarAway = Location.create(farFarAway, 1.0, 1.0, 0.0, 0.0, true, Instant.now().minusMillis(30000))
val recentLocationWithNoAccuracy = Location.create(nullIsland, Double.NaN, Double.NaN, 0.0, 0.0, false, Instant.now().minusMillis(50))

assert(!shouldUpdateCamera(recentLocationAtNullIsland, cameraAtNullIsland))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Better use Truth instead of junit asserts? We have Truth already in the version catalog, just need to add it to the gradle file.

* @since 200.7.0
*/
@Composable
private fun TrackLocationWithLocationDataSource(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Based on naming conventions, since this composable function doesn't return anything, it should be pascal cased and a noun. Also could make the name a bit shorter? How about:

Suggested change
private fun TrackLocationWithLocationDataSource(
private fun LocationTracker(

Camera(
location.position.y,
location.position.x,
if (location.position.hasZ) location.position.z!! else 1.0,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Better set z to 0.0 if we don't have a z value? This is what Swift is doing.

Suggested change
if (location.position.hasZ) location.position.z!! else 1.0,
if (location.position.hasZ) location.position.z!! else 0.0,


// We have to do this or the error gets bigger and bigger.
cameraController.transformationMatrix =
TransformationMatrix.createIdentityMatrix()

This comment was marked as resolved.

): Boolean {
// filter out old locations
if (Instant.now()
.toEpochMilli() - location.timestamp.toEpochMilli() > 10.0.seconds.inWholeMilliseconds
Copy link
Collaborator

Choose a reason for hiding this comment

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

Let's keep thresholds centralized. How about some internal object data WorldScaleParameters class?

* @since 200.7.0
*/
@Composable
private fun requestPermissionsOrFail(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Since this function returns a remembered state variable, I think we should prefix it with "remember", based on convention. How about:

Suggested change
private fun requestPermissionsOrFail(
private fun rememberPermissionsGranted(

modifier: Modifier = Modifier,
onInitializationStatusChanged: ((WorldScaleSceneViewStatus) -> Unit)? = null,
requestCameraPermissionAutomatically: Boolean = true,
requestLocationPermissionAutomatically: Boolean = true,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if we should have just a single requestPermissionsAutomatically parameter? What's the use case for having separate ones? We can solve this separate from this PR though.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Let's discuss this. I think there may be use cases for having them separate, but I don't know if it's critical

launch {
locationDataSource.status.collect {
var isLocationDataSourceStarted by remember { mutableStateOf(false) }
if (hasCompletedPermissionRequest && context.checkPermissionsGranted()) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

When hasCompletedPermissionRequest is true, doesn't this mean that all required permissions have been granted? Why do we need to then check again with checkPermissionsGranted()?

Comment on lines 39 to 41
scope.launch {
locationDataSource.stop()
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

When the wrapper is destroyed, we should just cancel the scope:

Suggested change
scope.launch {
locationDataSource.stop()
}
scope.cancel()

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I put this inside the launch, since otherwise I think we defeat the purpose of the wrapper. Let's discuss.

}
}

fun startLocationDataSource() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could do this instead in onResume?

Suggested change
fun startLocationDataSource() {
override fun onResume(owner: LifecycleOwner) {


/**
* Checks the permissions required for the [WorldScaleSceneView] to function and requests them if necessary.
* If [requestCameraPermissionAutomatically] or [requestLocationPermissionAutomatically] are set to false,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This doc is out of date

ActivityResultContracts.RequestMultiplePermissions()
) { grantedState: Map<String, Boolean> ->
// This callback will only be relevant to the permissions that were added to the request.
// If the user has set [requestCameraPermissionAutomatically] to false, for example,
Copy link
Collaborator

Choose a reason for hiding this comment

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

This comment is out of date

onInitializationStatusChanged: ((WorldScaleSceneViewStatus) -> Unit)?
): State<Boolean> {
val allPermissionsGranted = remember { mutableStateOf(false) }
val permissionsToRequest = mutableListOf<String>()
Copy link
Collaborator

Choose a reason for hiding this comment

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

Where does this list get populated?

*/
@Composable
private fun LocationTracker(
locationDataSource: LocationDataSource,
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we can remove this parameter and keep the LDS as an internal remembered variable of this function. The LDS is not used y the client code.

* @since 200.7.0
*/
internal class LocationDataSourceWrapper(val locationDataSource: LocationDataSource) : DefaultLifecycleObserver {
// This ensures that we can stop the location data source on a scope that won't be cancelled when the composition is destroyed
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// This ensures that we can stop the location data source on a scope that won't be cancelled when the composition is destroyed
// coroutine scope that is tied to the lifecycle of this LocationDataSourceWrapper

Comment on lines 376 to 377
wrapper.onDestroy(lifecycleOwner)
lifecycleOwner.lifecycle.removeObserver(wrapper)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think it' safer to do it in the opposite order:

Suggested change
wrapper.onDestroy(lifecycleOwner)
lifecycleOwner.lifecycle.removeObserver(wrapper)
lifecycleOwner.lifecycle.removeObserver(wrapper)
wrapper.onDestroy(lifecycleOwner)

}

return allPermissionsGranted
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

missing newline

import android.content.Context
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unused import

import com.arcgismaps.geometry.SpatialReference
import com.arcgismaps.location.Location
import com.arcgismaps.location.LocationDataSource
Copy link
Collaborator

Choose a reason for hiding this comment

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

Unused import

}

/**
* Starts the [locationDataSource] and periodically updates the [cameraController] with new locations
Copy link
Collaborator

Choose a reason for hiding this comment

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

locationDataSource parameter has been removed...

Suggested change
* Starts the [locationDataSource] and periodically updates the [cameraController] with new locations
* Starts a SystemLocationDataSource and periodically updates the [cameraController] with new locations

val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(Unit) {
// This will start the LocationDataSource
val wrapper = LocationDataSourceWrapper(locationDataSource)
Copy link
Collaborator

Choose a reason for hiding this comment

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

This LocationDataSourceWrapper needs to be kept in a remember, otherwise it could be garbage collected. See related comment below on rememberSystemLocationDataSource.

}

@Composable
private fun rememberSystemLocationDataSource(): SystemLocationDataSource {
Copy link
Collaborator

Choose a reason for hiding this comment

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

You could have this return the LocationDataSourceWrapper and also do the DisposableEffect in this remember function.

@hud10837 hud10837 requested a review from gunt0001 January 27, 2025 08:43
Copy link
Collaborator

@gunt0001 gunt0001 left a comment

Choose a reason for hiding this comment

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

@hud10837 - looks good. There are some refinements we can do regarding initialization. For example I noticed that the SceneView may render the globe at full extent (as if looking at it from space) on startup, before we have the initial location. This is something we should be able to avoid. But we can refine that in a separate PR.

@hud10837 hud10837 merged commit 0f6aa1d into feature-branches/world-scale-scene-view Jan 27, 2025
@hud10837 hud10837 deleted the hud10837/implement-world-tracking branch January 27, 2025 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants