diff --git a/.gitignore b/.gitignore index 35416e1426..97a5181304 100644 --- a/.gitignore +++ b/.gitignore @@ -65,3 +65,6 @@ project/localscripts jacoco.exec .env fastlane/report.xml + +# API keys +project/app/gradle.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000000..26d33521af --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml new file mode 100644 index 0000000000..51d2b6186d --- /dev/null +++ b/.idea/caches/deviceStreaming.xml @@ -0,0 +1,1258 @@ + + + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000000..c61ea3346e --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000000..639900d13c --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..0eaad78594 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/project/.claude/compose-migration-plan.md b/project/.claude/compose-migration-plan.md new file mode 100644 index 0000000000..4fcc2815b7 --- /dev/null +++ b/project/.claude/compose-migration-plan.md @@ -0,0 +1,329 @@ +# OwnTracks Compose Migration Plan + +## Overview +Migrating OwnTracks Android app from Activities/Fragments with XML layouts to Jetpack Compose with Navigation Compose. The drawer navigation has been replaced with bottom navigation. + +## Current Status + +### Completed Infrastructure +- [x] Compose dependencies added to `libs.versions.toml` +- [x] Compose enabled in `app/build.gradle.kts` +- [x] Theme files created (`ui/theme/Color.kt`, `Theme.kt`, `Type.kt`) +- [x] Navigation infrastructure (`ui/navigation/Destinations.kt`, `OwnTracksNavHost.kt`) +- [x] ~~Compose drawer component (`ui/navigation/AppDrawer.kt`)~~ (deleted - was deprecated) +- [x] Bottom navigation component (`ui/navigation/BottomNavBar.kt`) +- [x] Bottom nav menu (`res/menu/bottom_nav_menu.xml`) + +### Bottom Navigation Migration (COMPLETED) +Replaced drawer navigation with 4-tab bottom navigation across all main screens: + +| Tab | Icon | Screen | +|-----|------|--------| +| Map | map | Main map view | +| Contacts | people | Contact list | +| Waypoints | location_on | Regions/geofences | +| Preferences | settings | Settings + Status + About | + +**Changes made:** +- Created `BottomNavBar.kt` Compose component +- Created `bottom_nav_menu.xml` menu resource +- Updated `MapActivity` - removed DrawerLayout, added BottomNavigationView +- Updated `ContactsActivity/ContactsScreen` - replaced drawer with BottomNavBar +- Updated `WaypointsActivity/WaypointsScreen` - replaced drawer with BottomNavBar +- Updated `PreferencesActivity` - removed drawer, added BottomNavigationView +- Updated `StatusActivity/StatusScreen` - removed drawer, now uses back navigation (accessed from Preferences) +- Status, About, Exit are now handled directly in PreferencesScreen.kt Compose UI + +### Migrated Screens + +#### 1. Status Screen (Now accessed via Preferences) +- **Files**: `StatusActivity.kt`, `StatusScreen.kt` +- **Deleted**: `ui_status.xml` +- **Features**: Status items, battery optimization dialog, location permissions dialog, view logs button +- **Navigation**: Back button (accessed from Preferences > Status) + +#### 2. Contacts Screen +- **Files**: `ContactsActivity.kt`, `ContactsScreen.kt` +- **Deleted**: `ui_contacts.xml` +- **Features**: LazyColumn with contacts, contact avatar loading via `ContactImageBindingAdapter`, relative timestamps, geocoded locations +- **Navigation**: Bottom navigation bar + +#### 3. Waypoints Screen +- **Files**: `WaypointsActivity.kt`, `WaypointsScreen.kt` +- **Deleted**: `ui_waypoints.xml` +- **Features**: LazyColumn with waypoints, overflow menu (import/export), add button, geofence transition status +- **Navigation**: Bottom navigation bar + +#### 4. About Screen (Accessed via Preferences) +- **Files**: `AboutActivity.kt`, `AboutScreen.kt`, `LicensesScreen.kt` +- **Deleted**: `AboutFragment.kt`, `LicenseFragment.kt`, `about.xml`, `preferences_licenses.xml` +- **Features**: + - Version info with changelog link + - Documentation, License, Source Code links + - Libraries/Licenses screen (internal navigation) + - Translations with plural string support + - Feedback section (Issues, Mastodon) +- **Navigation**: Back button (accessed from Preferences > About) + +### Migrated Main Screens + +#### 5. Preferences Screen (MIGRATED TO COMPOSE - CLEANUP COMPLETED) +- **Files**: `PreferencesActivity.kt`, `PreferencesScreen.kt` +- **New Files**: + - `PreferenceItems.kt` - Reusable preference composables (SwitchPreference, EditTextPreference, ListPreference, etc.) + - `ConnectionPreferencesContent.kt` - Connection/server settings with reconnect action + - `MapPreferencesContent.kt` - Map display settings + - `ReportingPreferencesContent.kt` - Location reporting settings + - `NotificationPreferencesContent.kt` - Notification settings + - `AdvancedPreferencesContent.kt` - Advanced settings (services, locator, encryption, misc) + - `ExperimentalPreferencesContent.kt` - Experimental features placeholder +- **Deleted Legacy Files**: + - Kotlin: `PreferencesFragment.kt`, `AbstractPreferenceFragment.kt`, `ConnectionFragment.kt`, `MapFragment.kt`, `ReportingFragment.kt`, `NotificationFragment.kt`, `AdvancedFragment.kt`, `ExperimentalFragment.kt`, `ValidatingEditTextPreferenceDialogFragmentCompat.kt`, `PreferencesMenuProvider.kt` + - XML: `preferences_root.xml`, `preferences_connection.xml`, `preferences_map.xml`, `preferences_reporting.xml`, `preferences_notification.xml`, `preferences_advanced.xml`, `preferences_experimental.xml`, `menu/preferences_connection.xml` +- **Layout**: Uses `setContent` with Compose, no longer uses XML layouts or fragments +- **Navigation**: Bottom navigation bar via `BottomNavBar` composable +- **Contains**: Internal navigation to sub-screens (Connection, Map, Reporting, Notification, Advanced, Experimental) +- **Links to**: Status, About, Editor screens via Activity intents + +#### 6. Map Screen (Partial Compose Migration) +- **Current**: `MapActivity.kt`, `MapFragment.kt` (GMS/OSS variants), `ui_map.xml` +- **Layout**: CoordinatorLayout with BottomNavigationView + ComposeView overlay for FABs and bottom sheet +- **Navigation**: Bottom navigation bar +- **Features**: Map fragment, Compose FABs, Compose ModalBottomSheet for contact details +- **Bottom Sheet Migration (Phase 2 COMPLETED)**: + - Created `ContactBottomSheet.kt` - Compose ModalBottomSheet for contact details + - Displays contact info: avatar, name, timestamp, geocoded location + - Contact details grid: accuracy, altitude, battery, speed, distance, bearing + - Action buttons: Request Location, Navigate, Clear, Share + - Deleted `ui_contactsheet_parameter.xml` and `AutoResizingTextViewWithListener.kt` +- **FABs Migration (Phase 3 COMPLETED)**: + - Created `MapFabs.kt` - Compose component with MapLayersFab and MyLocationFab + - MyLocationFab shows dynamic icon based on `MyLocationStatus` (disabled, available, following) + - Removed FABs from `ui_map.xml` + - Removed `@BindingAdapter("locationIcon")` from MapActivity companion object + +### Secondary Screens (Back Navigation) + +##### 7.1 LogViewerActivity (Migrated) +- **Files**: `LogViewerActivity.kt`, `LogViewerScreen.kt` +- **Deleted**: `LogEntryAdapter.kt`, `LogPalette.kt`, `ui_preferences_logs.xml`, `log_viewer_entry.xml` +- **Features**: + - LazyColumn with log entries (horizontally scrollable) + - Color-coded log levels (debug, info, warning, error) + - Auto-scroll to bottom when new logs arrive + - Overflow menu (toggle debug logs, clear) + - FAB for sharing/exporting logs + - Back button navigation + +##### 7.2 WaypointActivity (Migrated) +- **Files**: `WaypointActivity.kt`, `WaypointScreen.kt` +- **Deleted**: `ui_waypoint.xml` +- **Features**: + - Form with OutlinedTextField for description, latitude, longitude, radius + - Input validation with error states + - Save/Delete action buttons in TopAppBar + - Delete confirmation dialog + - Back button navigation +- **Note**: Moved `@BindingAdapter` functions for `relativeTimeSpanString` to `support/BindingAdapters.kt` (still used by ui_row_contact.xml and ui_row_waypoint.xml) + +##### 7.3 WelcomeActivity (Migrated) +- **Files**: `BaseWelcomeActivity.kt`, `WelcomeScreen.kt`, `PlayServicesPage.kt` (gms) +- **Deleted**: + - Layouts: `ui_welcome.xml`, `ui_welcome_intro.xml`, `ui_welcome_connection_setup.xml`, `ui_welcome_location_permission.xml`, `ui_welcome_notification_permission.xml`, `ui_welcome_finish.xml`, `ui_welcome_play.xml` + - Kotlin: `WelcomeAdapter.kt`, `WelcomeViewModel.kt`, `WelcomeFragment.kt`, `IntroFragment.kt`, `ConnectionSetupFragment.kt`, `LocationPermissionFragment.kt`, `NotificationPermissionFragment.kt`, `FinishFragment.kt`, `PlayFragment.kt`, `PlayFragmentViewModel.kt` +- **Features**: + - HorizontalPager for swipeable onboarding pages + - Page indicator dots + - Permission requests with `rememberLauncherForActivityResult` + - GMS/OSS variants with different page lists + - Play Services check page (GMS only) + - Dynamic page list based on API level (NotificationPermission for Android 13+) + +##### 7.4 EditorActivity (Migrated) +- **Files**: `EditorActivity.kt`, `EditorScreen.kt` +- **Deleted**: `ui_preferences_editor.xml`, `ui_preferences_editor_dialog.xml` +- **Features**: + - Display effective configuration as JSON + - Overflow menu (export, import file, edit single value) + - Snackbar for feedback messages + - Preference editor dialog with autocomplete for keys (ExposedDropdownMenuBox) + - Export configuration to file picker + - Back button navigation + +##### 7.5 LoadActivity (Migrated) +- **Files**: `LoadActivity.kt`, `LoadScreen.kt` +- **Deleted**: `ui_preferences_load.xml` +- **Features**: + - Loading state with CircularProgressIndicator + - Success state showing imported configuration JSON + - Failed state showing error message + - Close/Save action buttons based on import status + - Handles ACTION_VIEW intents, content URIs, owntracks:// scheme + - File picker for manual import + +## Established Patterns + +### Activity Structure with Bottom Nav (Compose) +```kotlin +@AndroidEntryPoint +class SomeActivity : AppCompatActivity() { + private val viewModel: SomeViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + + setContent { + OwnTracksTheme { + SomeScreen( + viewModel = viewModel, + onNavigate = { destination -> + navigateToDestination(destination) + }, + // ... other callbacks + ) + } + } + } + + private fun navigateToDestination(destination: Destination) { + val activityClass = destination.toActivityClass() ?: return + if (this.javaClass != activityClass) { + startActivity(Intent(this, activityClass)) + } + } +} +``` + +### Screen Composable Structure with Bottom Nav +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SomeScreen( + // State from ViewModel + someData: List, + // Callbacks + onNavigate: (Destination) -> Unit, + onItemClick: (Item) -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary + ) + ) + }, + bottomBar = { + BottomNavBar( + currentDestination = Destination.SomeScreen, + onNavigate = onNavigate + ) + }, + modifier = modifier + ) { paddingValues -> + // Content + } +} +``` + +### Secondary Screen with Back Navigation +```kotlin +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SecondaryScreen( + onBackClick: () -> Unit, + modifier: Modifier = Modifier +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title)) }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) + } + }, + colors = TopAppBarDefaults.topAppBarColors(...) + ) + }, + modifier = modifier + ) { paddingValues -> + // Content + } +} +``` + +### State Collection +- StateFlow: `val state by viewModel.stateFlow.collectAsStateWithLifecycle()` +- LiveData: `val state by viewModel.liveData.observeAsState(initial = defaultValue)` +- Flow events: Use `LaunchedEffect` to collect + +### Hybrid View + Compose (MapActivity Pattern) +For screens that can't fully migrate to Compose (e.g., MapActivity with MapFragment): +```kotlin +// In XML layout, add a ComposeView for Compose overlay: + + +// In Activity, set Compose content on the ComposeView: +binding.composeBottomSheet.setContent { + OwnTracksTheme { + val state by viewModel.someState.observeAsState() + if (showBottomSheet) { + ContactBottomSheet( + contact = contact, + onDismiss = { /* handle dismiss */ }, + // ... other callbacks + ) + } + } +} +``` + +## Future Work + +### Potential Improvements +1. **Single Activity Architecture**: Consolidate all screens into a single MainActivity with NavHost +2. **Map Screen Compose Migration**: Phase 4 as outlined below (Phases 1-3 completed) +3. ~~**Preferences Screen Compose Migration**: Build custom Compose preferences UI~~ (COMPLETED) + +### Map Screen Migration Phases (Optional) +1. ~~**Phase 1 (Completed)**: Added bottom navigation, removed drawer~~ +2. ~~**Phase 2 (Completed)**: Migrated bottom sheet content to Compose ModalBottomSheet~~ +3. ~~**Phase 3 (Completed)**: Migrated FABs to Compose FloatingActionButton~~ +4. **Phase 4 (Full migration)**: Convert to single-Activity with NavHost + +## Build Command +```bash +./gradlew assembleGmsDebug --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-22" +``` + +## Dependencies Added +```toml +# In libs.versions.toml +compose-bom = "2024.12.01" +compose-compiler = "1.5.15" +navigation-compose = "2.8.5" +lifecycle-runtime-compose = "2.8.7" + +# Libraries +compose-bom, compose-ui, compose-ui-graphics, compose-ui-tooling, +compose-ui-tooling-preview, compose-material3, compose-material-icons-extended, +compose-foundation, compose-runtime-livedata, activity-compose, +navigation-compose, lifecycle-runtime-compose +``` + +## Notes +- Kotlin version is 1.9.25, requires Compose Compiler 1.5.x (not 2.x) +- DataBinding is still enabled for MapActivity (uses Compose overlay pattern) +- ViewModels remain unchanged - they work with both View system and Compose +- Hilt injection unchanged - @AndroidEntryPoint still works +- AppDrawer.kt deleted - bottom navigation is now used instead +- PreferencesActivity fully migrated to Compose - all legacy Fragment files and XML resources deleted +- SharedPreferencesStore.kt updated to remove references to deleted ConnectionFragment diff --git a/project/.claude/settings.local.json b/project/.claude/settings.local.json new file mode 100644 index 0000000000..01269eca41 --- /dev/null +++ b/project/.claude/settings.local.json @@ -0,0 +1,39 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew assembleGmsDebug:*)", + "Bash(./gradlew dependencies:*)", + "Bash(where java:*)", + "Bash(set \"JAVA_HOME=C:\\\\Program Files\\\\Java\\\\jdk-22\")", + "Bash(./gradlew compileGmsDebugKotlin:*)", + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(.gradlew.bat :app:compileGmsDebugKotlin)", + "Bash(./gradlew.bat:*)", + "Bash(./gradlew :app:compileOssDebugKotlin:*)", + "Bash(./gradlew :app:compileGmsDebugKotlin:*)", + "Bash(adb logcat:*)", + "Bash(git rev-parse:*)", + "Bash(git rm:*)", + "Bash(ls:*)", + "Bash(file:*)", + "Bash(git config:*)", + "Bash(~/.gitignore_global)", + "Bash(.\\\\gradlew:*)", + "WebSearch", + "Bash(java:*)", + "Bash(dir \"C:\\\\Program Files\\\\Java\")", + "Bash(dir:*)", + "Bash(\"C:\\\\Program Files\\\\Android\\\\Android Studio\\\\jbr\\\\bin\\\\java.exe\" -version)", + "WebFetch(domain:github.com)", + "WebFetch(domain:discuss.gradle.org)", + "Bash(./gradlew :app:transformGmsDebugClassesWithAsm:*)", + "WebFetch(domain:mvnrepository.com)", + "Bash(./gradlew :app:kspGmsDebugKotlin:*)", + "WebFetch(domain:dagger.dev)", + "Bash(./gradlew :app:dependencies:*)", + "Bash(./gradlew clean:*)", + "Bash(./gradlew:*)" + ] + } +} diff --git a/project/app/build.gradle.kts b/project/app/build.gradle.kts index 618adf7aaf..94d19c60f6 100644 --- a/project/app/build.gradle.kts +++ b/project/app/build.gradle.kts @@ -2,18 +2,18 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { id("com.android.application") - id("com.google.dagger.hilt.android") kotlin("android") - kotlin("kapt") - alias(libs.plugins.ktfmt) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) + id("com.google.dagger.hilt.android") + alias(libs.plugins.ktfmt) } apply() val googleMapsAPIKey = System.getenv("GOOGLE_MAPS_API_KEY")?.toString() - ?: extra.get("google_maps_api_key")?.toString() + ?: findProperty("google_maps_api_key")?.toString() ?: "PLACEHOLDER_API_KEY" val gmsImplementation: Configuration by configurations.creating @@ -124,12 +124,9 @@ android { buildFeatures { buildConfig = true - dataBinding = true - viewBinding = true + compose = true } - dataBinding { addKtx = true } - packaging { resources.excludes.add("META-INF/*") jniLibs.useLegacyPackaging = false @@ -185,26 +182,25 @@ android { isCoreLibraryDesugaringEnabled = true } - kotlinOptions { jvmTarget = JavaVersion.VERSION_21.toString() } - flavorDimensions.add("locationProvider") productFlavors { - create("gms") { - dimension = "locationProvider" - dependencies { - gmsImplementation(libs.gms.play.services.maps) - gmsImplementation(libs.play.services.location) - } - } + create("gms") { dimension = "locationProvider" } create("oss") { dimension = "locationProvider" } } } -kapt { - useBuildCache = true - correctErrorTypes = true +kotlin { + compilerOptions { + jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21) + } } +// kapt block disabled - using KSP instead +// kapt { +// useBuildCache = true +// correctErrorTypes = true +// } + ksp { arg("room.schemaLocation", "$projectDir/schemas") } tasks.withType { @@ -219,8 +215,15 @@ tasks.withType().configureEach { options.isFork = true } dependencies { implementation(libs.bundles.kotlin) implementation(libs.bundles.androidx) + implementation(libs.lifecycle.service) implementation(libs.androidx.test.espresso.idling) + // Compose + implementation(platform(libs.compose.bom)) + implementation(libs.bundles.compose) + implementation(libs.hilt.navigation.compose) + debugImplementation(libs.compose.ui.tooling) + implementation(libs.google.material) // Explicit dependency on conscrypt to give up-to-date TLS support on all devices @@ -228,6 +231,9 @@ dependencies { // Mapping implementation(libs.osmdroid) + "gmsImplementation"(libs.gms.play.services.maps) + "gmsImplementation"(libs.play.services.location) + "gmsImplementation"(libs.maps.compose) // Connectivity implementation(libs.paho.mqttclient) @@ -252,14 +258,13 @@ dependencies { implementation(libs.widgets.materialize) { artifact { type = "aar" } } // These Java EE libs are no longer included in JDKs, so we include explicitly - kapt(libs.bundles.jaxb.annotation.processors) + // kapt(libs.bundles.jaxb.annotation.processors) // Temporarily disabled to test KSP - // Preprocessors - kapt(libs.bundles.kapt.hilt) + // Preprocessors (using KSP) + ksp(libs.hilt.compiler) + ksp(libs.hilt.androidx) ksp(libs.androidx.room.compiler) - kaptTest(libs.bundles.kapt.hilt) - testImplementation(libs.mockito.kotlin) testImplementation(libs.androidx.core.testing) testImplementation(libs.kotlin.coroutines.test) @@ -268,7 +273,6 @@ dependencies { // Hilt Android Testing androidTestImplementation(libs.hilt.android.testing) - kaptAndroidTest(libs.hilt.compiler) androidTestImplementation(libs.barista) { exclude("org.jetbrains.kotlin") } androidTestImplementation(libs.okhttp.mockwebserver) @@ -278,4 +282,5 @@ dependencies { androidTestUtil(libs.bundles.androidx.test.util) coreLibraryDesugaring(libs.desugar) + } diff --git a/project/app/gradle.properties b/project/app/gradle.properties deleted file mode 100644 index d4c5b00e83..0000000000 --- a/project/app/gradle.properties +++ /dev/null @@ -1 +0,0 @@ -google_maps_api_key=PLACEHOLDER_API_KEY diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/ContactMarkers.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/ContactMarkers.kt new file mode 100644 index 0000000000..13c03bbbca --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/ContactMarkers.kt @@ -0,0 +1,70 @@ +package org.owntracks.android.ui.map + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.geometry.Offset +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import org.owntracks.android.gms.location.toGMSLatLng +import org.owntracks.android.model.Contact +import org.owntracks.android.support.ContactImageBindingAdapter + +/** + * Renders all contact markers on the Google Map. Each contact with a valid location is displayed as + * a marker with their avatar image. + * + * @param contacts Map of contact IDs to Contact objects + * @param contactImageBindingAdapter Adapter for loading contact avatar bitmaps + * @param onMarkerClick Callback when a marker is clicked, receives the contact ID + */ +@Composable +fun ContactMarkers( + contacts: Map, + contactImageBindingAdapter: ContactImageBindingAdapter, + onMarkerClick: (String) -> Unit +) { + contacts.values.forEach { contact -> + contact.latLng?.let { latLng -> + ContactMarker( + contact = contact, + contactImageBindingAdapter = contactImageBindingAdapter, + onMarkerClick = { onMarkerClick(contact.id) }) + } + } +} + +/** + * Renders a single contact marker with their avatar image. + * + * @param contact The contact to display + * @param contactImageBindingAdapter Adapter for loading the contact's avatar bitmap + * @param onMarkerClick Callback when this marker is clicked + */ +@Composable +private fun ContactMarker( + contact: Contact, + contactImageBindingAdapter: ContactImageBindingAdapter, + onMarkerClick: () -> Unit +) { + // Load the contact's avatar bitmap asynchronously + val bitmap by + produceState(initialValue = null, contact.id, contact.face, contact.trackerId) { + value = contactImageBindingAdapter.getBitmapFromCache(contact) + } + + // Only render the marker once we have both a location and a bitmap + val latLng = contact.latLng + if (bitmap != null && latLng != null) { + Marker( + state = MarkerState(position = latLng.toGMSLatLng()), + icon = bitmap!!.toBitmapDescriptor(), + anchor = Offset(0.5f, 0.5f), + tag = contact.id, + onClick = { + onMarkerClick() + true + }) + } +} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapContent.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapContent.kt new file mode 100644 index 0000000000..5898168774 --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapContent.kt @@ -0,0 +1,219 @@ +package org.owntracks.android.ui.map + +import android.annotation.SuppressLint +import android.location.Location +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.model.CameraPosition +import com.google.maps.android.compose.CameraMoveStartedReason +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapType +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.rememberCameraPositionState +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import org.owntracks.android.data.repos.ContactsRepoChange +import org.owntracks.android.data.waypoints.WaypointModel +import org.owntracks.android.data.waypoints.WaypointsRepo +import org.owntracks.android.gms.location.toGMSLatLng +import org.owntracks.android.location.LatLng +import org.owntracks.android.location.geofencing.Latitude +import org.owntracks.android.location.geofencing.Longitude +import org.owntracks.android.location.toLatLng +import org.owntracks.android.preferences.Preferences +import org.owntracks.android.support.ContactImageBindingAdapter + +/** + * Pure Compose implementation of Google Maps for the OwnTracks map screen. Replaces the + * Fragment-based GoogleMapFragment with native Compose components using the maps-compose library. + * + * @param viewModel The MapViewModel containing all map state + * @param contactImageBindingAdapter Adapter for loading contact avatar bitmaps + * @param preferences Application preferences for map configuration + * @param modifier Modifier for the composable + */ +@SuppressLint("MissingPermission") +@Composable +fun GoogleMapContent( + viewModel: MapViewModel, + contactImageBindingAdapter: ContactImageBindingAdapter, + preferences: Preferences, + modifier: Modifier = Modifier +) { + // Get initial camera position from ViewModel + val initialPosition = remember { viewModel.initMapStartingLocation() } + + // Create camera position state + val cameraPositionState = rememberCameraPositionState { + position = + CameraPosition.Builder() + .target(initialPosition.latLng.toGMSLatLng()) + .zoom(convertStandardZoomToGoogleZoom(initialPosition.zoom).toFloat()) + .bearing( + if (preferences.enableMapRotation) { + convertBetweenStandardRotationAndBearing(initialPosition.rotation) + } else { + 0f + }) + .build() + } + + // Observe map center for programmatic camera moves using flow collection + // This ensures camera animates on every emission, even when the same location is posted again + // drop(1) skips the initial value since we already set initial position above + LaunchedEffect(Unit) { + viewModel.mapCenter.asFlow() + .drop(1) // Drop initial LiveData value before filtering + .filterNotNull() + .collectLatest { center -> + cameraPositionState.animate(CameraUpdateFactory.newLatLng(center.toGMSLatLng())) + } + } + + // Handle camera gestures - trigger map click when user pans/zooms + LaunchedEffect(cameraPositionState) { + snapshotFlow { + cameraPositionState.isMoving to cameraPositionState.cameraMoveStartedReason + } + .collectLatest { (isMoving, reason) -> + if (isMoving && reason == CameraMoveStartedReason.GESTURE) { + viewModel.onMapClick() + } + } + } + + // Update ViewModel when camera stops moving + LaunchedEffect(cameraPositionState) { + snapshotFlow { cameraPositionState.isMoving to cameraPositionState.position } + .drop(1) // Skip initial value + .collectLatest { (isMoving, position) -> + if (!isMoving) { + viewModel.setMapLocationFromMapMoveEvent( + MapLocationZoomLevelAndRotation( + LatLng( + Latitude(position.target.latitude), Longitude(position.target.longitude)), + convertGoogleZoomToStandardZoom(position.zoom.toDouble()), + convertBetweenStandardRotationAndBearing(position.bearing))) + } + } + } + + // Observe map layer style for map type changes + val mapLayerStyle by viewModel.mapLayerStyle.observeAsState() + + // Observe current location for blue dot and sending location + val currentLocation by viewModel.currentLocation.observeAsState() + + // Handle sending location when GPS fix becomes available + val sendingLocation by viewModel.sendingLocation.observeAsState(false) + LaunchedEffect(currentLocation, sendingLocation) { + if (sendingLocation && currentLocation != null) { + viewModel.onLocationAvailableWhileSending(currentLocation!!) + } + } + + // Update blue dot location in ViewModel + LaunchedEffect(currentLocation) { + currentLocation?.let { location -> viewModel.setCurrentBlueDotLocation(location.toLatLng()) } + } + + // Follow device location in Device view mode + val viewMode = viewModel.viewMode + LaunchedEffect(currentLocation, viewMode) { + if (viewMode == MapViewModel.ViewMode.Device && currentLocation != null) { + cameraPositionState.animate( + CameraUpdateFactory.newLatLng(currentLocation!!.toLatLng().toGMSLatLng())) + } + } + + // Collect contacts with a version counter to trigger recomposition + val contactsVersion by + produceState(0) { + viewModel.contactUpdatedEvent.collect { + value++ // Increment to trigger recomposition on any contact change + } + } + + // Waypoints state - initially empty, populated from suspending call and updates + val waypoints by + produceState>(emptySet()) { + // Initial load + value = viewModel.getAllWaypoints().toSet() + + // Observe updates + viewModel.waypointUpdatedEvent.collect { operation -> + value = + when (operation) { + is WaypointsRepo.WaypointOperation.Clear -> emptySet() + is WaypointsRepo.WaypointOperation.Delete -> value - operation.waypoint + is WaypointsRepo.WaypointOperation.Insert -> value + operation.waypoint + is WaypointsRepo.WaypointOperation.Update -> + value.filterNot { it.id == operation.waypoint.id }.toSet() + operation.waypoint + is WaypointsRepo.WaypointOperation.InsertMany -> + value + operation.waypoints.toSet() + } + } + } + + // Get map style for dark mode + val mapStyleOptions = rememberMapStyleOptions() + + // Determine map type from layer style + val mapType = + when (mapLayerStyle) { + MapLayerStyle.GoogleMapDefault -> MapType.NORMAL + MapLayerStyle.GoogleMapHybrid -> MapType.HYBRID + MapLayerStyle.GoogleMapSatellite -> MapType.SATELLITE + MapLayerStyle.GoogleMapTerrain -> MapType.TERRAIN + else -> MapType.NORMAL + } + + // Check if we have location permission + val hasLocationPermission = viewModel.hasLocationPermission() + + GoogleMap( + modifier = modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = + MapProperties( + isMyLocationEnabled = hasLocationPermission, + mapType = mapType, + mapStyleOptions = mapStyleOptions, + minZoomPreference = 4f, + maxZoomPreference = 20f, + isIndoorEnabled = false), + uiSettings = + MapUiSettings( + myLocationButtonEnabled = false, + compassEnabled = preferences.enableMapRotation, + rotationGesturesEnabled = preferences.enableMapRotation, + zoomControlsEnabled = false, + mapToolbarEnabled = false), + onMapClick = { viewModel.onMapClick() }) { + // Render contact markers - use key to force recomposition on contact changes + androidx.compose.runtime.key(contactsVersion) { + ContactMarkers( + contacts = viewModel.allContacts, + contactImageBindingAdapter = contactImageBindingAdapter, + onMarkerClick = { id -> viewModel.onMarkerClick(id) }) + } + + // Render region overlays + RegionOverlays(waypoints = waypoints, showRegions = preferences.showRegionsOnMap) + } + + // Notify ViewModel that map is ready + LaunchedEffect(Unit) { viewModel.onMapReady() } +} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapFragment.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapFragment.kt index 33b6d50f87..215166fb7d 100644 --- a/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapFragment.kt +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/GoogleMapFragment.kt @@ -28,9 +28,9 @@ import com.google.android.gms.maps.model.CircleOptions import com.google.android.gms.maps.model.MapStyleOptions import com.google.android.gms.maps.model.Marker import com.google.android.gms.maps.model.MarkerOptions +import com.google.android.gms.maps.MapView import org.owntracks.android.R import org.owntracks.android.data.waypoints.WaypointModel -import org.owntracks.android.databinding.GoogleMapFragmentBinding import org.owntracks.android.gms.location.toGMSLatLng import org.owntracks.android.location.LatLng import org.owntracks.android.location.geofencing.Latitude @@ -47,7 +47,7 @@ internal constructor( private val preferences: Preferences, contactImageBindingAdapter: ContactImageBindingAdapter ) : - MapFragment(contactImageBindingAdapter, preferences), + MapFragment(contactImageBindingAdapter, preferences), OnMapReadyCallback, OnMapsSdkInitializedCallback { @@ -56,6 +56,8 @@ internal constructor( override val layout: Int get() = R.layout.google_map_fragment + private lateinit var googleMapView: MapView + private val googleMapLocationSource: LocationSource by lazy { object : LocationSource { private var locationObserver: Observer? = null @@ -86,8 +88,9 @@ internal constructor( savedInstanceState: Bundle? ): View { val root = super.onCreateView(inflater, container, savedInstanceState) - binding.googleMapView.onCreate(savedInstanceState) - binding.googleMapView.getMapAsync(this) + googleMapView = root.findViewById(R.id.google_map_view) + googleMapView.onCreate(savedInstanceState) + googleMapView.getMapAsync(this) return root } @@ -205,37 +208,37 @@ internal constructor( override fun onResume() { super.onResume() - binding.googleMapView.onResume() + googleMapView.onResume() setMapStyle() } override fun onLowMemory() { - binding.googleMapView.onLowMemory() + googleMapView.onLowMemory() super.onLowMemory() } override fun onPause() { - binding.googleMapView.onPause() + googleMapView.onPause() super.onPause() } override fun onDestroy() { - binding.googleMapView.onDestroy() + googleMapView.onDestroy() super.onDestroy() } override fun onSaveInstanceState(outState: Bundle) { - binding.googleMapView.onSaveInstanceState(outState) + googleMapView.onSaveInstanceState(outState) super.onSaveInstanceState(outState) } override fun onStart() { super.onStart() - binding.googleMapView.onStart() + googleMapView.onStart() } override fun onStop() { - binding.googleMapView.onStop() + googleMapView.onStop() super.onStop() } diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/MapComposeUtils.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/MapComposeUtils.kt new file mode 100644 index 0000000000..b95fb95b28 --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/MapComposeUtils.kt @@ -0,0 +1,104 @@ +package org.owntracks.android.ui.map + +import android.content.Context +import android.graphics.Bitmap +import android.util.TypedValue +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.google.android.gms.maps.model.BitmapDescriptor +import com.google.android.gms.maps.model.BitmapDescriptorFactory +import com.google.android.gms.maps.model.MapStyleOptions +import org.owntracks.android.R +import org.owntracks.android.ui.map.osm.OSMMapFragment + +private const val GOOGLE_MIN_ZOOM = 4.0 +private const val GOOGLE_MAX_ZOOM = 20.0 + +/** Convert a Bitmap to a BitmapDescriptor for use with Google Maps Compose markers. */ +fun Bitmap.toBitmapDescriptor(): BitmapDescriptor = BitmapDescriptorFactory.fromBitmap(this) + +/** + * Converts standard (OSM) zoom to Google Maps zoom level. Simple linear conversion. + * + * @param inputZoom Zoom level from standard (OSM) + * @return Equivalent zoom level on Google Maps + */ +fun convertStandardZoomToGoogleZoom(inputZoom: Double): Double = + linearConversion( + OSMMapFragment.MIN_ZOOM_LEVEL..OSMMapFragment.MAX_ZOOM_LEVEL, + GOOGLE_MIN_ZOOM..GOOGLE_MAX_ZOOM, + inputZoom.coerceIn(OSMMapFragment.MIN_ZOOM_LEVEL, OSMMapFragment.MAX_ZOOM_LEVEL)) + +/** + * Converts Google Maps zoom to Standard (OSM) zoom level. Simple linear conversion. + * + * @param inputZoom Zoom level from Google Maps + * @return Equivalent zoom level on Standard (OSM) + */ +fun convertGoogleZoomToStandardZoom(inputZoom: Double): Double = + linearConversion( + GOOGLE_MIN_ZOOM..GOOGLE_MAX_ZOOM, + OSMMapFragment.MIN_ZOOM_LEVEL..OSMMapFragment.MAX_ZOOM_LEVEL, + inputZoom.coerceIn(GOOGLE_MIN_ZOOM, GOOGLE_MAX_ZOOM)) + +/** + * Linear conversion of a point in a range to the equivalent point in another range. + * + * @param fromRange Starting range the given point is in + * @param toRange Range to translate the point to + * @param point point in the starting range + * @return a value that's at the same location in [toRange] as [point] is in [fromRange] + */ +private fun linearConversion( + fromRange: ClosedRange, + toRange: ClosedRange, + point: Double +): Double { + return ((point - fromRange.start) / (fromRange.endInclusive - fromRange.start)) * + (toRange.endInclusive - toRange.start) + toRange.start +} + +/** + * Convert standard rotation to google bearing. OSM uses a "map rotation" concept to represent how + * the map is oriented, whereas google uses the "bearing". These are not the same thing, so this + * converts from a rotation to a bearing and back again (because it's reversible). + * + * @param input rotation or bearing value + * @return an equivalent bearing or rotation + */ +fun convertBetweenStandardRotationAndBearing(input: Float): Float = -input % 360 + +/** + * Remembers the map style options based on dark theme preference. Returns null for light theme, + * and the night theme style for dark theme. + */ +@Composable +fun rememberMapStyleOptions(): MapStyleOptions? { + val context = LocalContext.current + val isDarkTheme = isSystemInDarkTheme() + + return remember(isDarkTheme) { + if (isDarkTheme) { + MapStyleOptions.loadRawResourceStyle(context, R.raw.google_maps_night_theme) + } else { + null + } + } +} + +/** Gets the region color from the current theme. */ +fun getRegionColor(context: Context): Int { + val typedValue = TypedValue() + context.theme.resolveAttribute(R.attr.colorRegion, typedValue, true) + return typedValue.data +} + +/** Gets the region color from the current theme as a Compose Color. */ +@Composable +fun rememberRegionColor(): Color { + val context = LocalContext.current + return remember { Color(getRegionColor(context)) } +} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/MapContentCompose.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/MapContentCompose.kt new file mode 100644 index 0000000000..cfde340390 --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/MapContentCompose.kt @@ -0,0 +1,29 @@ +package org.owntracks.android.ui.map + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.owntracks.android.preferences.Preferences +import org.owntracks.android.support.ContactImageBindingAdapter + +/** + * Returns true if Compose-based maps should be used for the given map layer style. In GMS flavor, + * this returns true for Google Maps styles. + */ +fun shouldUseComposeMaps(mapLayerStyle: MapLayerStyle?): Boolean = mapLayerStyle?.isGoogleMaps() ?: true + +/** + * Compose-based map content for GMS flavor. Renders GoogleMapContent for Google Maps styles. + */ +@Composable +fun MapContentCompose( + viewModel: MapViewModel, + contactImageBindingAdapter: ContactImageBindingAdapter, + preferences: Preferences, + modifier: Modifier = Modifier +) { + GoogleMapContent( + viewModel = viewModel, + contactImageBindingAdapter = contactImageBindingAdapter, + preferences = preferences, + modifier = modifier) +} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/MapLayerStyle.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/MapLayerStyle.kt index 9fb989b150..3f62ac2099 100644 --- a/project/app/src/gms/java/org/owntracks/android/ui/map/MapLayerStyle.kt +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/MapLayerStyle.kt @@ -1,6 +1,6 @@ package org.owntracks.android.ui.map -import androidx.databinding.ViewDataBinding +import com.google.android.gms.maps.GoogleMap import org.owntracks.android.R import org.owntracks.android.preferences.types.FromConfiguration import org.owntracks.android.ui.map.osm.OSMMapFragment @@ -19,7 +19,28 @@ enum class MapLayerStyle { } } - fun getFragmentClass(): Class> { + /** Returns true if this layer style uses Google Maps, false for OpenStreetMap. */ + fun isGoogleMaps(): Boolean = + when (this) { + GoogleMapDefault, + GoogleMapHybrid, + GoogleMapSatellite, + GoogleMapTerrain -> true + OpenStreetMapNormal, + OpenStreetMapWikimedia -> false + } + + /** Returns the Google Maps map type constant for this layer style. */ + fun toGoogleMapType(): Int = + when (this) { + GoogleMapDefault -> GoogleMap.MAP_TYPE_NORMAL + GoogleMapHybrid -> GoogleMap.MAP_TYPE_HYBRID + GoogleMapSatellite -> GoogleMap.MAP_TYPE_SATELLITE + GoogleMapTerrain -> GoogleMap.MAP_TYPE_TERRAIN + else -> GoogleMap.MAP_TYPE_NORMAL + } + + fun getFragmentClass(): Class { return when (this) { GoogleMapDefault, GoogleMapHybrid, diff --git a/project/app/src/gms/java/org/owntracks/android/ui/map/RegionOverlays.kt b/project/app/src/gms/java/org/owntracks/android/ui/map/RegionOverlays.kt new file mode 100644 index 0000000000..938937195c --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/map/RegionOverlays.kt @@ -0,0 +1,51 @@ +package org.owntracks.android.ui.map + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.toArgb +import com.google.maps.android.compose.Circle +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.MarkerState +import org.owntracks.android.data.waypoints.WaypointModel +import org.owntracks.android.gms.location.toGMSLatLng +import org.owntracks.android.location.toLatLng + +/** + * Renders waypoint regions on the Google Map as circles with markers. Each waypoint is displayed as + * a filled circle (geofence area) with a marker at the center showing the description. + * + * @param waypoints Collection of waypoints to display + * @param showRegions Whether to show regions on the map (from preferences) + */ +@Composable +fun RegionOverlays(waypoints: Collection, showRegions: Boolean) { + if (!showRegions) return + + val regionColor = rememberRegionColor() + + waypoints.forEach { waypoint -> RegionOverlay(waypoint = waypoint, fillColor = regionColor.toArgb()) } +} + +/** + * Renders a single waypoint region as a circle with a marker. + * + * @param waypoint The waypoint to display + * @param fillColor The fill color for the circle (with alpha) + */ +@Composable +private fun RegionOverlay(waypoint: WaypointModel, fillColor: Int) { + val position = waypoint.getLocation().toLatLng().toGMSLatLng() + + // Draw the geofence circle + Circle( + center = position, + radius = waypoint.geofenceRadius.toDouble(), + fillColor = androidx.compose.ui.graphics.Color(fillColor), + strokeWidth = 1f, + strokeColor = androidx.compose.ui.graphics.Color(fillColor).copy(alpha = 0.8f)) + + // Draw the marker at the center with the description + Marker( + state = MarkerState(position = position), + title = waypoint.description, + anchor = androidx.compose.ui.geometry.Offset(0.5f, 1.0f)) +} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/welcome/PlayServicesPage.kt b/project/app/src/gms/java/org/owntracks/android/ui/welcome/PlayServicesPage.kt new file mode 100644 index 0000000000..d404ac00d9 --- /dev/null +++ b/project/app/src/gms/java/org/owntracks/android/ui/welcome/PlayServicesPage.kt @@ -0,0 +1,161 @@ +package org.owntracks.android.ui.welcome + +import android.app.Activity +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import kotlinx.coroutines.launch +import org.owntracks.android.R + +/** + * GMS-specific page for checking Google Play Services availability + */ +@Composable +fun PlayServicesPage( + onCanProceed: (Boolean) -> Unit, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val activity = context as? Activity + val scope = rememberCoroutineScope() + val googleApi = remember { GoogleApiAvailability.getInstance() } + + var playServicesAvailable by remember { mutableStateOf(false) } + var fixAvailable by remember { mutableStateOf(false) } + var statusMessage by remember { mutableStateOf("") } + + fun checkPlayServices() { + val result = googleApi.isGooglePlayServicesAvailable(context) + when (result) { + ConnectionResult.SUCCESS -> { + playServicesAvailable = true + fixAvailable = false + statusMessage = context.getString(R.string.play_services_now_available) + onCanProceed(true) + } + ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, + ConnectionResult.SERVICE_UPDATING -> { + playServicesAvailable = false + fixAvailable = true + statusMessage = context.getString(R.string.play_services_update_required) + onCanProceed(false) + } + else -> { + playServicesAvailable = false + fixAvailable = googleApi.isUserResolvableError(result) + statusMessage = context.getString(R.string.play_services_not_available) + onCanProceed(false) + } + } + } + + // Check on composition + LaunchedEffect(Unit) { + checkPlayServices() + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { + Spacer(modifier = Modifier.height(48.dp)) + + Icon( + imageVector = Icons.Default.Warning, + contentDescription = stringResource(R.string.icon_description_warning), + modifier = Modifier.size(80.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Text( + text = stringResource(R.string.welcome_play_heading), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = stringResource(R.string.welcome_play_description), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + lineHeight = 24.sp + ) + + if (statusMessage.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = statusMessage, + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = if (playServicesAvailable) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + if (fixAvailable && activity != null) { + OutlinedButton( + onClick = { + val result = googleApi.isGooglePlayServicesAvailable(context) + if (!googleApi.showErrorDialogFragment( + activity, + result, + PLAY_SERVICES_RESOLUTION_REQUEST + ) + ) { + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.play_services_not_available) + ) + } + } + // Re-check after dialog + checkPlayServices() + } + ) { + Text(stringResource(R.string.welcomeFixIssue)) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +const val PLAY_SERVICES_RESOLUTION_REQUEST = 1 diff --git a/project/app/src/gms/java/org/owntracks/android/ui/welcome/WelcomeActivity.kt b/project/app/src/gms/java/org/owntracks/android/ui/welcome/WelcomeActivity.kt index 6140c059c4..a3778b23ff 100644 --- a/project/app/src/gms/java/org/owntracks/android/ui/welcome/WelcomeActivity.kt +++ b/project/app/src/gms/java/org/owntracks/android/ui/welcome/WelcomeActivity.kt @@ -1,30 +1,39 @@ package org.owntracks.android.ui.welcome -import android.content.Intent +import android.os.Build +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import org.owntracks.android.ui.welcome.fragments.PlayFragment @AndroidEntryPoint class WelcomeActivity : BaseWelcomeActivity() { - @Inject lateinit var playFragment: PlayFragment - override val fragmentList by lazy { - listOf( - introFragment, - connectionSetupFragment, - locationPermissionFragment, - notificationPermissionFragment, - playFragment, - finishFragment) - } + private val googleApi by lazy { GoogleApiAvailability.getInstance() } - @Deprecated("Deprecated in Java") - @Suppress("DEPRECATION") - public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (requestCode == PlayFragment.PLAY_SERVICES_RESOLUTION_REQUEST) { - playFragment.onPlayServicesResolutionResult() + override val welcomePages: List by lazy { + buildList { + add(WelcomePage.Intro) + add(WelcomePage.ConnectionSetup) + add(WelcomePage.LocationPermission) + // Notification permission only for Android 13+ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + add(WelcomePage.NotificationPermission) + } + // Play Services page only if not available + if (googleApi.isGooglePlayServicesAvailable(this@WelcomeActivity) != ConnectionResult.SUCCESS) { + add(WelcomePage.PlayServices) + } + add(WelcomePage.Finish) + } } - } + + override val playServicesPageContent: (@Composable (snackbarHostState: SnackbarHostState, onCanProceed: (Boolean) -> Unit) -> Unit)? + get() = { snackbarHostState, onCanProceed -> + PlayServicesPage( + onCanProceed = onCanProceed, + snackbarHostState = snackbarHostState + ) + } } diff --git a/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragment.kt b/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragment.kt deleted file mode 100644 index 2f0abe4e8f..0000000000 --- a/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragment.kt +++ /dev/null @@ -1,89 +0,0 @@ -package org.owntracks.android.ui.welcome.fragments - -import android.content.Context -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.fragment.app.viewModels -import com.google.android.gms.common.ConnectionResult -import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.material.snackbar.Snackbar -import dagger.hilt.android.AndroidEntryPoint -import javax.inject.Inject -import org.owntracks.android.R -import org.owntracks.android.databinding.UiWelcomePlayBinding -import org.owntracks.android.ui.welcome.WelcomeViewModel - -@AndroidEntryPoint -class PlayFragment @Inject constructor() : WelcomeFragment() { - private val playFragmentViewModel: PlayFragmentViewModel by viewModels() - private lateinit var binding: UiWelcomePlayBinding - private val googleAPI = GoogleApiAvailability.getInstance() - - override fun shouldBeDisplayed(context: Context): Boolean = - googleAPI.isGooglePlayServicesAvailable(context) != ConnectionResult.SUCCESS - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - binding = - UiWelcomePlayBinding.inflate(inflater, container, false).apply { - vm = playFragmentViewModel - lifecycleOwner = this@PlayFragment.viewLifecycleOwner - recover.setOnClickListener { requestFix() } - } - return binding.root - } - - fun onPlayServicesResolutionResult() { - checkGooglePlayservicesIsAvailable() - } - - private fun requestFix() { - val result = googleAPI.isGooglePlayServicesAvailable(requireContext()) - - if (!googleAPI.showErrorDialogFragment( - requireActivity(), result, PLAY_SERVICES_RESOLUTION_REQUEST)) { - Snackbar.make( - binding.root, getString(R.string.play_services_not_available), Snackbar.LENGTH_SHORT) - .show() - } - checkGooglePlayservicesIsAvailable() - } - - private fun checkGooglePlayservicesIsAvailable() { - val nextEnabled = - when (val result = googleAPI.isGooglePlayServicesAvailable(requireContext())) { - ConnectionResult.SUCCESS -> { - playFragmentViewModel.setPlayServicesAvailable( - getString(R.string.play_services_now_available)) - WelcomeViewModel.ProgressState.PERMITTED - } - ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED, - ConnectionResult.SERVICE_UPDATING -> { - playFragmentViewModel.setPlayServicesNotAvailable( - true, getString(R.string.play_services_update_required)) - WelcomeViewModel.ProgressState.NOT_PERMITTED - } - else -> { - playFragmentViewModel.setPlayServicesNotAvailable( - googleAPI.isUserResolvableError(result), - getString(R.string.play_services_not_available)) - WelcomeViewModel.ProgressState.NOT_PERMITTED - } - } - viewModel.setWelcomeState(nextEnabled) - } - - override fun onResume() { - super.onResume() - checkGooglePlayservicesIsAvailable() - } - - companion object { - const val PLAY_SERVICES_RESOLUTION_REQUEST = 1 - } -} diff --git a/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragmentViewModel.kt b/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragmentViewModel.kt deleted file mode 100644 index aaef8a2484..0000000000 --- a/project/app/src/gms/java/org/owntracks/android/ui/welcome/fragments/PlayFragmentViewModel.kt +++ /dev/null @@ -1,26 +0,0 @@ -package org.owntracks.android.ui.welcome.fragments - -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject - -@HiltViewModel -class PlayFragmentViewModel @Inject constructor() : ViewModel() { - fun setPlayServicesAvailable(message: String) { - mutableMessage.postValue(message) - mutablePlayServicesFixAvailable.postValue(false) - } - - fun setPlayServicesNotAvailable(fixAvailable: Boolean, message: String) { - mutableMessage.postValue(message) - mutablePlayServicesFixAvailable.postValue(fixAvailable) - } - - private val mutableMessage = MutableLiveData() - val message: LiveData = mutableMessage - - private val mutablePlayServicesFixAvailable = MutableLiveData(false) - val playServicesFixAvailable: LiveData = mutablePlayServicesFixAvailable -} diff --git a/project/app/src/gms/res/layout/google_map_fragment.xml b/project/app/src/gms/res/layout/google_map_fragment.xml index f1d96b4a86..4df4530623 100644 --- a/project/app/src/gms/res/layout/google_map_fragment.xml +++ b/project/app/src/gms/res/layout/google_map_fragment.xml @@ -1,16 +1,11 @@ - + - - - - - - - + + diff --git a/project/app/src/gms/res/layout/ui_welcome_play.xml b/project/app/src/gms/res/layout/ui_welcome_play.xml deleted file mode 100644 index 05bd543ce8..0000000000 --- a/project/app/src/gms/res/layout/ui_welcome_play.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - -