diff --git a/phoneClients/android/app/src/main/AndroidManifest.xml b/phoneClients/android/app/src/main/AndroidManifest.xml index 3623e14f..8184db9b 100644 --- a/phoneClients/android/app/src/main/AndroidManifest.xml +++ b/phoneClients/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,17 @@ - - - + + + - - - - - - - + + + + + + + android:enableOnBackInvokedCallback="true" + tools:targetApi="35"> + android:screenOrientation="portrait"> - - - - \ No newline at end of file + diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/GpsTrackerApplication.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/GpsTrackerApplication.kt index 3dd0538e..0a7fb785 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/GpsTrackerApplication.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/GpsTrackerApplication.kt @@ -1,37 +1,21 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/GpsTrackerApplication.kt package com.websmithing.gpstracker2 import android.app.Application import dagger.hilt.android.HiltAndroidApp import timber.log.Timber -/** - * Main application class for the GPS Tracker app. - * - * This class serves as the entry point for the application and handles initialization of - * app-wide components such as logging via Timber. It's annotated with [HiltAndroidApp] - * to enable dependency injection throughout the application. - */ @HiltAndroidApp class GpsTrackerApplication : Application() { - /** - * Initializes the application when it's first created. - * - * Sets up Timber for logging with different configurations based on the build type: - * - Debug builds: Uses [Timber.DebugTree] for detailed console logging - * - Release builds: Placeholder for a production-appropriate logging implementation - */ override fun onCreate() { super.onCreate() - // Initialize Timber - // TODO: Plant a different tree for release builds (e.g., one that logs to crash reporting) - if (BuildConfig.DEBUG) { // BuildConfig needs to be generated by Gradle sync + setupTimber() + } + + private fun setupTimber() { + if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) - Timber.d("Timber DebugTree planted.") - } else { - // Timber.plant(CrashReportingTree()) // Example for release - Timber.d("Timber ReleaseTree planted (placeholder).") // Placeholder log + Timber.d("Timber DebugTree planted") } } -} \ No newline at end of file +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/MainActivity.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/MainActivity.kt index 8d16eaa9..317988a2 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/MainActivity.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/MainActivity.kt @@ -1,50 +1,26 @@ package com.websmithing.gpstracker2 import android.content.Context -import android.content.pm.ActivityInfo -import android.graphics.Color import android.os.Bundle -import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent -import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import com.websmithing.gpstracker2.di.SettingsRepositoryEntryPoint +import com.websmithing.gpstracker2.helper.LocaleHelper import com.websmithing.gpstracker2.ui.App -import com.websmithing.gpstracker2.ui.checkFirstTimeLoading import com.websmithing.gpstracker2.ui.checkIfGooglePlayEnabled -import com.websmithing.gpstracker2.util.LocaleHelper import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking -/** - * Main activity for the GPS Tracker application. - * - * This activity serves as the primary user interface for the GPS tracking functionality. - * It handles: - * - User configuration (username, server URL, tracking interval) - * - Permission management for location tracking - * - Starting and stopping the tracking service - * - Displaying real-time location data and tracking statistics - * - Communicating with the backend ViewModel that manages data and services - * - * The activity is integrated with Hilt for dependency injection. - */ @AndroidEntryPoint class MainActivity : AppCompatActivity() { - override fun attachBaseContext(newBase: Context) { - // 1. Get the EntryPoint accessor from the application context - val entryPoint = EntryPointAccessors.fromApplication( - newBase.applicationContext, - SettingsRepositoryEntryPoint::class.java - ) - - // 2. Use the EntryPoint to get the repository instance - val repo = entryPoint.getSettingsRepository() - - // 3. Use your LocaleHelper to create the new context - val newCtx = LocaleHelper.onAttach(newBase, repo) + override fun attachBaseContext(newBase: Context) { + val repo = getSettingsRepository(newBase) + val newCtx = runBlocking { + LocaleHelper.wrapContext(newBase, repo) + } super.attachBaseContext(newCtx) } @@ -52,17 +28,15 @@ class MainActivity : AppCompatActivity() { installSplashScreen() super.onCreate(savedInstanceState) - requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT - checkIfGooglePlayEnabled() - checkFirstTimeLoading() - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - ) setContent { App() } } + + private fun getSettingsRepository(context: Context) = EntryPointAccessors.fromApplication( + context.applicationContext, + SettingsRepositoryEntryPoint::class.java + ).settingsRepository() } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepository.kt deleted file mode 100644 index e697ea1d..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepository.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.websmithing.gpstracker2.data.repository - -import android.location.Location -import kotlinx.coroutines.flow.Flow - -/** - * Provides a most recent location data in foreground mode and is not related to the logic of - * sending tracking data over the network - */ -interface ForegroundLocationRepository { - val currentLocation: Flow - - fun start() - - fun stop() -} \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepositoryImpl.kt deleted file mode 100644 index 762109da..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/ForegroundLocationRepositoryImpl.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.websmithing.gpstracker2.data.repository - -import android.annotation.SuppressLint -import android.location.Location -import android.os.Looper -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.Priority -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import timber.log.Timber -import java.util.concurrent.TimeUnit -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class ForegroundLocationRepositoryImpl @Inject constructor( - private val provider: FusedLocationProviderClient, -) : ForegroundLocationRepository { - private val _currentLocation = MutableStateFlow(null) - override val currentLocation: StateFlow = _currentLocation - - private var callback: LocationCallback? = null - private var initialFixReceived = false - - @SuppressLint("MissingPermission") - override fun start() { - if (callback != null) { - return - } - - callback = object : LocationCallback() { - override fun onLocationResult(result: LocationResult) { - val loc = result.lastLocation ?: return - initialFixReceived = true - _currentLocation.value = loc - Timber.d("Callback fix: ${loc.latitude}, ${loc.longitude}") - } - } - - requestInitialFix() - startLocationUpdates() - } - - override fun stop() { - callback?.let { - provider.removeLocationUpdates(it) - Timber.d("Location updates stopped") - } - callback = null - } - - @SuppressLint("MissingPermission") - private fun requestInitialFix() { - provider.lastLocation.addOnSuccessListener { loc -> - if (loc != null) { - initialFixReceived = true - _currentLocation.value = loc - Timber.d("Initial fix from lastLocation") - } - } - - provider.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) - .addOnSuccessListener { loc -> - if (!initialFixReceived && loc != null) { - initialFixReceived = true - _currentLocation.value = loc - Timber.d("Initial fix from getCurrentLocation") - } - } - } - - @SuppressLint("MissingPermission") - private fun startLocationUpdates() { - val request = LocationRequest.Builder( - Priority.PRIORITY_HIGH_ACCURACY, - TimeUnit.SECONDS.toMillis(60) - ) - .setMinUpdateIntervalMillis(TimeUnit.SECONDS.toMillis(30)) - .setMaxUpdateDelayMillis(TimeUnit.MINUTES.toMillis(2)) - .build() - - provider.requestLocationUpdates( - request, - callback!!, - Looper.getMainLooper() - ) - Timber.d("Location updates started") - } -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepository.kt deleted file mode 100644 index 56442d1a..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepository.kt +++ /dev/null @@ -1,110 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepository.kt -package com.websmithing.gpstracker2.data.repository - -import android.location.Location -import kotlinx.coroutines.flow.Flow - -/** - * Represents the status of a location upload attempt. - * - * This sealed class provides different states for tracking the upload process: - * - [Idle]: Initial state or state after processing a success/failure - * - [Success]: Upload was successful - * - [Failure]: Upload failed with an optional error message - */ -sealed class UploadStatus { - /** - * Initial state or state after processing a success/failure - */ - object Idle : UploadStatus() - - /** - * Indicates a successful location data upload - */ - object Success : UploadStatus() - - /** - * Indicates a failed location data upload with an optional error message - * - * @property errorMessage Optional error message describing the failure reason - */ - data class Failure(val errorMessage: String?) : UploadStatus() -} - -/** - * Repository interface for handling location operations in the GPS Tracker app. - * - * This interface defines the contract for location-related operations: - * - Retrieving and observing location data - * - Tracking distance traveled - * - Uploading location data to a remote server - * - Managing location state for calculations - */ -interface LocationRepository { - - /** - * A flow emitting the latest known device location. - * - * Emits null if no location has been received yet. - */ - val latestLocation: Flow - - /** - * A flow emitting the total distance traveled in meters since tracking started. - */ - val totalDistance: Flow - - /** - * A flow emitting the status of the last location upload attempt. - */ - val lastUploadStatus: Flow - - /** - * Fetches the current device location synchronously. - * - * @return The current location or null if location could not be determined - * @throws SecurityException if location permissions are not granted - */ - suspend fun getCurrentLocation(): Location? - - /** - * Uploads the provided location data to the remote server. - * - * @param location The location data to upload - * @param username The username identifying this tracker - * @param appId Unique identifier for this device/installation - * @param sessionId Unique identifier for this tracking session - * @param eventType Type of tracking event (e.g., "start", "stop", "update") - * @return true if upload was successful, false otherwise - */ - suspend fun uploadLocationData( - location: Location, - username: String, - appId: String, - sessionId: String, - eventType: String - ): Boolean - - /** - * Retrieves the previously saved location point. - * - * @return The previously saved location or null if no previous location is stored - */ - suspend fun getPreviousLocation(): Location? - - /** - * Saves the current location as the "previous" location for the next calculation. - * Also updates the total distance calculation based on this new location. - * - * @param location The location to save as the current location - */ - suspend fun saveAsPreviousLocation(location: Location) - - /** - * Resets the location state for a new tracking session. - * - * Clears the previous location, resets the total distance to zero, - * and resets the upload status to Idle. - */ - suspend fun resetLocationState() -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepositoryImpl.kt deleted file mode 100644 index b865cfac..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepositoryImpl.kt +++ /dev/null @@ -1,404 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/data/repository/LocationRepositoryImpl.kt -package com.websmithing.gpstracker2.data.repository - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import android.location.Location -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.Priority -import com.websmithing.gpstracker2.network.ApiService -import com.websmithing.gpstracker2.util.PermissionChecker -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import timber.log.Timber -import java.net.URLEncoder -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.math.roundToInt - -/** - * Implementation of the [LocationRepository] interface. - * - * This class handles: - * - Retrieving location from Google Play Services - * - Calculating distance traveled - * - Persisting location state between app sessions - * - Formatting and uploading location data to a remote server - * - Managing StateFlows for real-time UI updates - * - * It uses: - * - [FusedLocationProviderClient] for location data - * - Retrofit/OkHttp for network communication - * - SharedPreferences for local state persistence - * - Coroutines for asynchronous operations - * - StateFlows for reactive data updates - */ -@Singleton -class LocationRepositoryImpl @Inject constructor( - @param:ApplicationContext private val appContext: Context, - private val fusedLocationClient: FusedLocationProviderClient, - private val okHttpClient: OkHttpClient, - private val retrofitBuilder: Retrofit.Builder, - private val settingsRepository: SettingsRepository, - private val permissionChecker: PermissionChecker -) : LocationRepository { - - // Initialize SharedPreferences - /** - * SharedPreferences instance for persisting location data between app sessions - */ - private val sharedPreferences: SharedPreferences = - appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - // --- State Flows --- - /** - * Internal mutable state flow for the latest location - */ - private val _latestLocation = MutableStateFlow(null) - - /** - * Publicly exposed immutable state flow of the latest location - */ - override val latestLocation: StateFlow = _latestLocation.asStateFlow() - - /** - * Internal mutable state flow for the total distance in meters - */ - private val _totalDistance = MutableStateFlow(0f) - - /** - * Publicly exposed immutable state flow of the total distance - */ - override val totalDistance: StateFlow = _totalDistance.asStateFlow() - - /** - * Internal mutable state flow for the upload status - */ - private val _lastUploadStatus = MutableStateFlow(UploadStatus.Idle) - - /** - * Publicly exposed immutable state flow of the upload status - */ - override val lastUploadStatus: StateFlow = _lastUploadStatus.asStateFlow() - - /** - * Initializes the repository with fresh state. - * - * In a production app, we might want to restore state from persistent storage - * in case the app was restarted during an active tracking session. - */ - init { - Timber.d("LocationRepositoryImpl initialized.") - } - - /** - * Gets the current device location using the FusedLocationProviderClient. - * - * This method uses a suspendCancellableCoroutine to convert the callback-based - * FusedLocationProviderClient API into a coroutine-compatible suspending function. - * - * @return The current location, or null if location could not be determined - * @throws SecurityException If location permissions are not granted - */ - @SuppressLint("MissingPermission") - override suspend fun getCurrentLocation(): Location? = withContext(Dispatchers.IO) { - if (!permissionChecker.hasLocationPermission()) { - Timber.e("Attempted to get location without permission") - throw SecurityException("Location permission not granted.") - } - - suspendCancellableCoroutine { continuation -> - Timber.d("Requesting current location...") - fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .addOnSuccessListener { location: Location? -> - if (continuation.isActive) continuation.resume(location) - } - .addOnFailureListener { e -> - Timber.e(e, "Location failure") - if (continuation.isActive) continuation.resumeWithException(e) - } - .addOnCanceledListener { - Timber.d("Location request cancelled") - if (continuation.isActive) continuation.cancel() - } - continuation.invokeOnCancellation { /* Optional: Cancel location request */ } - } - } - - /** - * Uploads location data to a remote server. - * - * This method: - * 1. Formats and encodes location data - * 2. Determines the correct server URL (with fallbacks) - * 3. Creates a dynamic Retrofit service with the target URL - * 4. Makes the network request - * 5. Processes the response and updates the upload status flow - * - * @param location The location data to upload - * @param username The username identifying this tracker - * @param appId Unique identifier for this device/installation - * @param sessionId Unique identifier for this tracking session - * @param eventType Type of tracking event (e.g., "start", "stop", "update") - * @return true if upload was successful, false otherwise - */ - override suspend fun uploadLocationData( - location: Location, - username: String, - appId: String, - sessionId: String, - eventType: String - ): Boolean = withContext(Dispatchers.IO) { - var success = false - var errorMessage: String? = null - try { - Timber.tag(TAG).i("REPO-CRITICAL: Starting location upload process") - - // Format and encode data - val formattedDate = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).apply { - timeZone = TimeZone.getDefault() - }.format(Date(location.time)) - val encodedDate = try { - URLEncoder.encode(formattedDate, "UTF-8") - } catch (e: Exception) { - formattedDate - } - val encodedMethod = try { - URLEncoder.encode(location.provider ?: "unknown", "UTF-8") - } catch (e: Exception) { - location.provider ?: "unknown" - } - - // Prepare numeric data - val speedMph = (location.speed * 2.2369).roundToInt() - val accuracyMeters = location.accuracy.roundToInt() - val altitudeMeters = location.altitude.roundToInt() - val direction = location.bearing.roundToInt() - val currentTotalDistanceMeters = _totalDistance.value - val totalDistanceMiles = - currentTotalDistanceMeters / 1609.34f // Convert meters to miles for API - - // Get server URL - var targetUrl = settingsRepository.getCurrentWebsiteUrl() - Timber.tag(TAG).i("REPO-CRITICAL: Got URL from settings: $targetUrl") - if (targetUrl.isBlank()) { - Timber.tag(TAG).e("Website URL is blank. Using default URL.") - targetUrl = "https://www.websmithing.com/gpstracker/api/locations/update" - } - - // Ensure URL is properly formatted - if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) { - targetUrl = "https://" + targetUrl - Timber.tag(TAG).d("Added https:// to URL: $targetUrl") - } - - // Ensure URL has the correct endpoint - if (!targetUrl.contains("/update") && !targetUrl.contains("/api/")) { - if (targetUrl.endsWith("/")) { - targetUrl += "gpstracker/api/locations/update" - } else { - targetUrl += "/gpstracker/api/locations/update" - } - Timber.tag(TAG).d("Appended default endpoint: $targetUrl") - } - - // Parse URL for Retrofit - val httpUrl = targetUrl.toHttpUrlOrNull() - if (httpUrl == null) { - Timber.tag(TAG).e("Invalid URL format after processing: $targetUrl") - return@withContext false - } - - // Build base URL - val pathSegments = httpUrl.pathSegments.filter { it.isNotEmpty() } - val baseUrl: String - - if (pathSegments.size <= 1) { - val tempUrl = httpUrl.newBuilder().query(null).fragment(null).build().toString() - baseUrl = if (tempUrl.endsWith("/")) tempUrl else "$tempUrl/" - } else { - val basePath = "/" + pathSegments.dropLast(1).joinToString("/") + "/" - baseUrl = httpUrl.newBuilder() - .encodedPath(basePath) - .query(null) - .fragment(null) - .build() - .toString() - } - - // Ensure baseUrl ends with a slash - val finalBaseUrl = if (!baseUrl.endsWith("/")) "$baseUrl/" else baseUrl - Timber.tag(TAG).d("Using base URL: $finalBaseUrl") - - // Create API service - val dynamicApiService = retrofitBuilder - .baseUrl(finalBaseUrl) - .build() - .create(ApiService::class.java) - - // Make API call with error handling - val response = try { - Timber.tag(TAG).i("REPO-CRITICAL: About to make API call with Retrofit") - dynamicApiService.updateLocation( - latitude = location.latitude.toString(), - longitude = location.longitude.toString(), - speed = speedMph, - direction = direction, - date = encodedDate, - locationMethod = encodedMethod, - username = username, - phoneNumber = appId, - sessionId = sessionId, - accuracy = accuracyMeters, - extraInfo = altitudeMeters.toString(), - eventType = eventType - ) - } catch (e: Exception) { - Timber.tag(TAG).e(e, "REPO-CRITICAL: Exception during API call") - return@withContext false - } - - Timber.tag(TAG) - .i("REPO-CRITICAL: Got response code: ${response.code()}, message: ${response.message()}") - - // Process response - val responseBody = response.body() - if (response.isSuccessful && responseBody != null && responseBody != "-1") { - Timber.tag(TAG).i("Upload successful. Server response: $responseBody") - success = true - return@withContext true - } else { - // Log more details about the failure - val failureReason = when { - !response.isSuccessful -> { - val errorBodyString = try { - response.errorBody()?.string() - } catch (e: Exception) { - "Error reading error body: ${e.message}" - } - "HTTP error. Code: ${response.code()}, Message: ${response.message()}, Body: $errorBodyString" - } - - responseBody == null -> "Response body was null." - responseBody == "-1" -> "Server returned error code: -1." - else -> "Unexpected successful response body: $responseBody" - } - Timber.tag(TAG).e("Upload failed: $failureReason") - errorMessage = failureReason - return@withContext false - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Unhandled exception during upload") - errorMessage = e.localizedMessage ?: "Unknown upload error" - success = false - } finally { - // Update the status flow regardless of outcome - Timber.tag(TAG).d("Finally block: success=$success, errorMessage='$errorMessage'") - _lastUploadStatus.value = - if (success) UploadStatus.Success else UploadStatus.Failure(errorMessage) - } - return@withContext success - } - - /** - * Retrieves the previously saved location from SharedPreferences. - * - * @return The previously saved location, or null if no location was saved - */ - override suspend fun getPreviousLocation(): Location? = withContext(Dispatchers.IO) { - val lat = sharedPreferences.getFloat(KEY_PREVIOUS_LATITUDE, 0f) - val lon = sharedPreferences.getFloat(KEY_PREVIOUS_LONGITUDE, 0f) - - if (lat != 0f && lon != 0f) { - Location("").apply { - latitude = lat.toDouble() - longitude = lon.toDouble() - } - } else { - null - } - } - - /** - * Saves the current location and updates distance calculations. - * - * This method: - * 1. Retrieves the previous location from the state flow - * 2. Calculates the distance increment if there was a previous location - * 3. Updates the total distance state flow - * 4. Updates the latest location state flow - * 5. Persists the current location to SharedPreferences - * - * @param location The new location to save - */ - override suspend fun saveAsPreviousLocation(location: Location) = withContext(Dispatchers.IO) { - val previousLocation = _latestLocation.value - - if (previousLocation != null) { - val distanceIncrement = location.distanceTo(previousLocation) // Distance in meters - _totalDistance.update { it + distanceIncrement } - Timber.d("Distance updated: +${distanceIncrement}m, Total: ${_totalDistance.value}m") - } else { - Timber.d("First location received, distance starts at 0.") - } - - // Update the latest location flow - _latestLocation.value = location - - // Persist coordinates for potential app restart - sharedPreferences.edit().apply { - putFloat(KEY_PREVIOUS_LATITUDE, location.latitude.toFloat()) - putFloat(KEY_PREVIOUS_LONGITUDE, location.longitude.toFloat()) - apply() - } - Timber.tag(TAG) - .d("Updated location state: Lat=${location.latitude}, Lon=${location.longitude}, TotalDist=${_totalDistance.value}m") - } - - /** - * Resets all location state for a new tracking session. - * - * This method: - * 1. Clears the latest location state flow - * 2. Resets the total distance to zero - * 3. Sets the upload status to Idle - * 4. Removes persisted location data from SharedPreferences - */ - override suspend fun resetLocationState() = withContext(Dispatchers.IO) { - _latestLocation.value = null - _totalDistance.value = 0f - _lastUploadStatus.value = UploadStatus.Idle - sharedPreferences.edit().apply { - remove(KEY_PREVIOUS_LATITUDE) - remove(KEY_PREVIOUS_LONGITUDE) - apply() - } - Timber.i("Location state reset.") - } - - /** - * Constants used by this repository implementation - */ - companion object { - private const val TAG = "LocationRepository" - private const val PREFS_NAME = "com.websmithing.gpstracker2.location_prefs" - private const val KEY_PREVIOUS_LATITUDE = "previousLatitude" - private const val KEY_PREVIOUS_LONGITUDE = "previousLongitude" - } -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepository.kt deleted file mode 100644 index 48f10f13..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepository.kt +++ /dev/null @@ -1,185 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepository.kt -package com.websmithing.gpstracker2.data.repository - -import kotlinx.coroutines.flow.Flow - -/** - * Repository interface for managing user settings and tracking state. - * - * This interface defines the contract for persisting and retrieving - * settings, session data, and tracking state for the GPS tracker application. - * It provides both synchronous and asynchronous (Flow) methods for accessing settings. - */ -interface SettingsRepository { - - // --- Tracking State --- - /** - * Sets the current tracking state. - * - * @param isTracking True if tracking is active, false otherwise - */ - suspend fun setTrackingState(isTracking: Boolean) - - /** - * Provides a Flow that emits the current tracking state whenever it changes. - * - * @return A Flow emitting the current tracking state - */ - fun isTracking(): Flow - - /** - * Gets the current tracking state synchronously. - * - * @return True if tracking is active, false otherwise - */ - suspend fun getCurrentTrackingState(): Boolean - - // --- User Settings --- - /** - * Saves the username for identifying this tracker's data. - * - * @param username The username to save - */ - suspend fun saveUsername(username: String) - - /** - * Provides a Flow that emits the current username whenever it changes. - * - * @return A Flow emitting the current username - */ - fun getUsername(): Flow - - /** - * Gets the current username synchronously. - * - * @return The current username - */ - suspend fun getCurrentUsername(): String - - /** - * Saves the tracking interval in minutes. - * - * @param intervalMinutes The interval in minutes between location updates - */ - suspend fun saveTrackingInterval(intervalMinutes: Int) - - /** - * Provides a Flow that emits the current tracking interval whenever it changes. - * - * @return A Flow emitting the current tracking interval in minutes - */ - fun getTrackingInterval(): Flow - - /** - * Gets the current tracking interval synchronously. - * - * @return The current tracking interval in minutes - */ - suspend fun getCurrentTrackingInterval(): Int - - /** - * Saves the website URL where location data will be uploaded. - * - * @param url The website URL to save - */ - suspend fun saveWebsiteUrl(url: String) - - /** - * Provides a Flow that emits the current website URL whenever it changes. - * - * @return A Flow emitting the current website URL - */ - fun getWebsiteUrl(): Flow - - /** - * Gets the current website URL synchronously. - * - * @return The current website URL - */ - suspend fun getCurrentWebsiteUrl(): String - - // --- Session/Device IDs --- - /** - * Saves a new session ID for the current tracking session. - * - * @param sessionId The session ID to save - */ - suspend fun saveSessionId(sessionId: String) - - /** - * Clears the current session ID, typically when tracking stops. - */ - suspend fun clearSessionId() - - /** - * Gets the current session ID synchronously. - * If no session ID exists, generates and saves a new one. - * - * @return The current session ID - */ - suspend fun getCurrentSessionId(): String - - /** - * Gets the app ID (device identifier) synchronously. - * The app ID is generated once when the app is first installed and remains constant. - * - * @return The app ID - */ - suspend fun getAppId(): String - - // --- First Time Check --- - /** - * Checks if this is the first time the app is being loaded. - * - * @return True if this is the first time loading, false otherwise - */ - suspend fun isFirstTimeLoading(): Boolean - - /** - * Sets the first-time loading flag. - * - * @param isFirst True to mark as first time loading, false otherwise - */ - suspend fun setFirstTimeLoading(isFirst: Boolean) - - /** - * Generates and saves a new app ID. - * This should only be called during first-time setup. - * - * @return The newly generated app ID - */ - suspend fun generateAndSaveAppId(): String - - // --- Location State --- - /** - * Resets location state for a new tracking session. - * This resets total distance and position flags. - */ - suspend fun resetLocationStateForNewSession() - - /** - * Saves the total distance traveled and position flag. - * - * @param totalDistance The total distance traveled in meters - * @param firstTime True if this is the first position in a tracking session - */ - suspend fun saveDistanceAndPositionFlags(totalDistance: Float, firstTime: Boolean) - - /** - * Gets the total distance traveled synchronously. - * - * @return The total distance traveled in meters - */ - suspend fun getTotalDistance(): Float - - /** - * Checks if this is the first time getting a position in the current tracking session. - * - * @return True if this is the first position, false otherwise - */ - suspend fun isFirstTimeGettingPosition(): Boolean - - fun saveLanguage(language: String) - - fun getCurrentLanguage(): String -} \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepositoryImpl.kt deleted file mode 100644 index b8d4420e..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepositoryImpl.kt +++ /dev/null @@ -1,350 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/data/repository/SettingsRepositoryImpl.kt -package com.websmithing.gpstracker2.data.repository - -import android.content.SharedPreferences -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.withContext -import java.util.UUID -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Implementation of the [SettingsRepository] interface using SharedPreferences. - * - * This class handles: - * - Storing and retrieving user settings for the GPS tracker - * - Managing tracking state - * - Handling session and device identifiers - * - Maintaining location-related state - * - * All methods use Kotlin coroutines with the IO dispatcher to ensure - * that shared preferences operations don't block the main thread. - */ -@Singleton -class SettingsRepositoryImpl @Inject constructor( - private val sharedPreferences: SharedPreferences -) : SettingsRepository { - - // --- Tracking State --- - /** - * Sets whether tracking is currently active. - * - * @param isTracking True if tracking is active, false otherwise - */ - override suspend fun setTrackingState(isTracking: Boolean) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().putBoolean(KEY_CURRENTLY_TRACKING, isTracking).apply() - } - } - - /** - * Provides a Flow that emits the current tracking state. - * - * Note: This is a basic implementation. For real-time updates, - * consider using a SharedPreferenceChangeListener or DataStore. - * - * @return A Flow emitting the current tracking state - */ - override fun isTracking(): Flow = flow { - emit(getCurrentTrackingState()) - } - - /** - * Gets the current tracking state synchronously. - * - * @return True if tracking is active, false otherwise - */ - override suspend fun getCurrentTrackingState(): Boolean { - return withContext(Dispatchers.IO) { - sharedPreferences.getBoolean(KEY_CURRENTLY_TRACKING, false) - } - } - - // --- User Settings --- - /** - * Saves the username for identifying this tracker's data. - * - * @param username The username to save - */ - override suspend fun saveUsername(username: String) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().putString(KEY_USER_NAME, username.trim()).apply() - } - } - - /** - * Provides a Flow that emits the current username. - * - * @return A Flow emitting the current username - */ - override fun getUsername(): Flow = flow { - emit(getCurrentUsername()) - } - - /** - * Gets the current username synchronously. - * - * @return The current username, or empty string if not set - */ - override suspend fun getCurrentUsername(): String { - return withContext(Dispatchers.IO) { - val storedUsername = sharedPreferences.getString(KEY_USER_NAME, "") ?: "" - storedUsername - } - } - - /** - * Saves the tracking interval in minutes. - * - * @param intervalMinutes The interval in minutes between location updates - */ - override suspend fun saveTrackingInterval(intervalMinutes: Int) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().putInt(KEY_INTERVAL_MINUTES, intervalMinutes).apply() - } - } - - /** - * Provides a Flow that emits the current tracking interval. - * - * @return A Flow emitting the current tracking interval in minutes - */ - override fun getTrackingInterval(): Flow = flow { - emit(getCurrentTrackingInterval()) - } - - /** - * Gets the current tracking interval synchronously. - * - * @return The current tracking interval in minutes, defaulting to 1 if not set - */ - override suspend fun getCurrentTrackingInterval(): Int { - return withContext(Dispatchers.IO) { - sharedPreferences.getInt(KEY_INTERVAL_MINUTES, 1) // Default to 1 min - } - } - - /** - * Saves the website URL where location data will be uploaded. - * - * @param url The website URL to save - */ - override suspend fun saveWebsiteUrl(url: String) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().putString(KEY_WEBSITE_URL, url.trim()).apply() - } - } - - /** - * Provides a Flow that emits the current website URL. - * - * @return A Flow emitting the current website URL - */ - override fun getWebsiteUrl(): Flow = flow { - emit(getCurrentWebsiteUrl()) - } - - /** - * Gets the current website URL synchronously. - * - * @return The current website URL, defaulting to the standard endpoint if not set - */ - override suspend fun getCurrentWebsiteUrl(): String { - return withContext(Dispatchers.IO) { - sharedPreferences.getString(KEY_WEBSITE_URL, "device.waliot.com:30032") - ?: "device.waliot.com:30032" - } - } - - // --- Session/Device IDs --- - /** - * Saves a new session ID for the current tracking session. - * - * @param sessionId The session ID to save - */ - override suspend fun saveSessionId(sessionId: String) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().putString(KEY_SESSION_ID, sessionId).apply() - } - } - - /** - * Clears the current session ID, typically when tracking stops. - */ - override suspend fun clearSessionId() { - withContext(Dispatchers.IO) { - sharedPreferences.edit().remove(KEY_SESSION_ID).apply() - } - } - - /** - * Gets the current session ID synchronously. - * If no session ID exists, generates and saves a new one. - * - * @return The current session ID - */ - override suspend fun getCurrentSessionId(): String { - return withContext(Dispatchers.IO) { - val storedSessionId = sharedPreferences.getString(KEY_SESSION_ID, "") ?: "" - if (storedSessionId.isBlank()) { - // Force a default session ID if none is set - val defaultSessionId = UUID.randomUUID().toString() - sharedPreferences.edit().putString(KEY_SESSION_ID, defaultSessionId).apply() - return@withContext defaultSessionId - } - storedSessionId - } - } - - /** - * Gets the app ID (device identifier) synchronously. - * The app ID is generated once when the app is first installed and remains constant. - * - * @return The app ID - */ - override suspend fun getAppId(): String { - return withContext(Dispatchers.IO) { - var appId = sharedPreferences.getString(KEY_APP_ID, null) - if (appId == null) { - appId = generateAndSaveAppIdInternal() - } - appId - } - } - - // --- First Time Check --- - /** - * Checks if this is the first time the app is being loaded. - * This is determined by the absence of an app ID. - * - * @return True if this is the first time loading, false otherwise - */ - override suspend fun isFirstTimeLoading(): Boolean { - return withContext(Dispatchers.IO) { - !sharedPreferences.contains(KEY_APP_ID) - } - } - - /** - * Sets the first-time loading flag. - * If not first time, ensures that an app ID exists. - * - * @param isFirst True to mark as first time loading, false otherwise - */ - override suspend fun setFirstTimeLoading(isFirst: Boolean) { - withContext(Dispatchers.IO) { - if (!isFirst) { - // Ensure App ID exists if we are marking it as "not first time" - getAppId() - } - // No direct "first time" flag is being set here anymore. - } - } - - /** - * Internal implementation of generating and saving a new app ID. - * - * @return The newly generated app ID - */ - private suspend fun generateAndSaveAppIdInternal(): String { - return withContext(Dispatchers.IO) { - val newId = UUID.randomUUID().toString() - sharedPreferences.edit().putString(KEY_APP_ID, newId).apply() - newId - } - } - - /** - * Generates and saves a new app ID. - * This should only be called during first-time setup. - * - * @return The newly generated app ID - */ - override suspend fun generateAndSaveAppId(): String { - return generateAndSaveAppIdInternal() - } - - // --- Location State --- - /** - * Resets location state for a new tracking session. - * This resets total distance and position flags. - */ - override suspend fun resetLocationStateForNewSession() { - withContext(Dispatchers.IO) { - sharedPreferences.edit().apply { - putFloat(KEY_TOTAL_DISTANCE, 0f) - putBoolean(KEY_FIRST_TIME_GETTING_POSITION, true) - remove(KEY_PREVIOUS_LATITUDE) - remove(KEY_PREVIOUS_LONGITUDE) - apply() - } - } - } - - /** - * Saves the total distance traveled and position flag. - * - * @param totalDistance The total distance traveled in meters - * @param firstTime True if this is the first position in a tracking session - */ - override suspend fun saveDistanceAndPositionFlags(totalDistance: Float, firstTime: Boolean) { - withContext(Dispatchers.IO) { - sharedPreferences.edit().apply { - putFloat(KEY_TOTAL_DISTANCE, totalDistance) - putBoolean(KEY_FIRST_TIME_GETTING_POSITION, firstTime) - apply() - } - } - } - - /** - * Gets the total distance traveled synchronously. - * - * @return The total distance traveled in meters - */ - override suspend fun getTotalDistance(): Float { - return withContext(Dispatchers.IO) { - sharedPreferences.getFloat(KEY_TOTAL_DISTANCE, 0f) - } - } - - /** - * Checks if this is the first time getting a position in the current tracking session. - * - * @return True if this is the first position, false otherwise - */ - override suspend fun isFirstTimeGettingPosition(): Boolean { - return withContext(Dispatchers.IO) { - sharedPreferences.getBoolean(KEY_FIRST_TIME_GETTING_POSITION, true) - } - } - - override fun saveLanguage(language: String) { - sharedPreferences.edit().putString(KEY_LANGUAGE, language).apply() - - } - - override fun getCurrentLanguage(): String { - return sharedPreferences.getString(KEY_LANGUAGE, "ru") ?: "ru" - } - - /** - * Constants used by this repository implementation - */ - companion object { - private const val PREFS_NAME = "com.websmithing.gpstracker2.prefs" - private const val KEY_CURRENTLY_TRACKING = "currentlyTracking" - private const val KEY_USER_NAME = "userName" - private const val KEY_INTERVAL_MINUTES = "intervalInMinutes" - private const val KEY_SESSION_ID = "sessionID" - private const val KEY_APP_ID = "appID" - private const val KEY_TOTAL_DISTANCE = "totalDistanceInMeters" - private const val KEY_FIRST_TIME_GETTING_POSITION = "firstTimeGettingPosition" - private const val KEY_PREVIOUS_LATITUDE = "previousLatitude" - private const val KEY_PREVIOUS_LONGITUDE = "previousLongitude" - private const val KEY_WEBSITE_URL = "defaultUploadWebsite" - private const val KEY_LANGUAGE = "language" - } -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/WialonIpsLocationRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/WialonIpsLocationRepositoryImpl.kt deleted file mode 100644 index 79973dd7..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/data/repository/WialonIpsLocationRepositoryImpl.kt +++ /dev/null @@ -1,423 +0,0 @@ -package com.websmithing.gpstracker2.data.repository - -import android.annotation.SuppressLint -import android.content.Context -import android.content.SharedPreferences -import android.location.Location -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.Priority -import com.websmithing.gpstracker2.util.PermissionChecker -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext -import timber.log.Timber -import java.net.Socket -import java.text.SimpleDateFormat -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.math.abs -import kotlin.math.roundToInt - - -/** - * Implementation of the [LocationRepository] interface with Wialon IPS 2.0 protocol. - * - * This class handles: - * - Retrieving location from Google Play Services - * - Calculating distance traveled - * - Persisting location state between app sessions - * - Formatting and uploading location data to a remote server - * - Managing StateFlows for real-time UI updates - * - * It uses: - * - [FusedLocationProviderClient] for location data - * - TCP/IP socket for network communication - * - SharedPreferences for local state persistence - * - Coroutines for asynchronous operations - * - StateFlows for reactive data updates - * - * @link https://extapi.wialon.com/hw/cfg/Wialon%20IPS_en_v_2_0.pdf - * - * @author binakot - */ -@Singleton -class WialonIpsLocationRepositoryImpl @Inject constructor( - @param:ApplicationContext private val appContext: Context, - private val fusedLocationClient: FusedLocationProviderClient, - private val settingsRepository: SettingsRepository, - private val permissionChecker: PermissionChecker -) : LocationRepository { - - // Initialize SharedPreferences - /** - * SharedPreferences instance for persisting location data between app sessions - */ - private val sharedPreferences: SharedPreferences = - appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - - // --- State Flows --- - /** - * Internal mutable state flow for the latest location - */ - private val _latestLocation = MutableStateFlow(null) - - /** - * Publicly exposed immutable state flow of the latest location - */ - override val latestLocation: StateFlow = _latestLocation.asStateFlow() - - /** - * Internal mutable state flow for the total distance in meters - */ - private val _totalDistance = MutableStateFlow(0f) - - /** - * Publicly exposed immutable state flow of the total distance - */ - override val totalDistance: StateFlow = _totalDistance.asStateFlow() - - /** - * Internal mutable state flow for the upload status - */ - private val _lastUploadStatus = MutableStateFlow(UploadStatus.Idle) - - /** - * Publicly exposed immutable state flow of the upload status - */ - override val lastUploadStatus: StateFlow = _lastUploadStatus.asStateFlow() - - private val dateFormatter = SimpleDateFormat("ddMMyy", Locale.US) - private val timeFormatter = SimpleDateFormat("HHmmss", Locale.US) - - /** - * Initializes the repository with fresh state. - * - * In a production app, we might want to restore state from persistent storage - * in case the app was restarted during an active tracking session. - */ - init { - dateFormatter.timeZone = TimeZone.getTimeZone("UTC") - timeFormatter.timeZone = TimeZone.getTimeZone("UTC") - - Timber.d("WialonIpsLocationRepositoryImpl initialized.") - } - - /** - * Gets the current device location using the FusedLocationProviderClient. - * - * This method uses a suspendCancellableCoroutine to convert the callback-based - * FusedLocationProviderClient API into a coroutine-compatible suspending function. - * - * @return The current location, or null if location could not be determined - * @throws SecurityException If location permissions are not granted - */ - @SuppressLint("MissingPermission") - override suspend fun getCurrentLocation(): Location? = withContext(Dispatchers.IO) { - if (!permissionChecker.hasLocationPermission()) { - Timber.e("Attempted to get location without permission") - throw SecurityException("Location permission not granted.") - } - - suspendCancellableCoroutine { continuation -> - Timber.d("Requesting current location...") - fusedLocationClient.getCurrentLocation(Priority.PRIORITY_BALANCED_POWER_ACCURACY, null) - .addOnSuccessListener { location: Location? -> - if (continuation.isActive) continuation.resume(location) - } - .addOnFailureListener { e -> - Timber.e(e, "Location failure") - if (continuation.isActive) continuation.resumeWithException(e) - } - .addOnCanceledListener { - Timber.d("Location request cancelled") - if (continuation.isActive) continuation.cancel() - } - continuation.invokeOnCancellation { /* Optional: Cancel location request */ } - } - } - - /** - * Uploads location data to a remote server. - * - * @param location The location data to upload - * @param username The username identifying this tracker - * @param appId Unique identifier for this device/installation - * @param sessionId Unique identifier for this tracking session - * @param eventType Type of tracking event (e.g., "start", "stop", "update") - * @return true if upload was successful, false otherwise - */ - override suspend fun uploadLocationData( - location: Location, - username: String, - appId: String, - sessionId: String, - eventType: String - ): Boolean = withContext(Dispatchers.IO) { - var success = false - var errorMessage: String? = null - try { - Timber.tag(TAG).i("REPO-CRITICAL: Starting location upload process") - - // Get server address - var serverAddress = settingsRepository.getCurrentWebsiteUrl() - Timber.tag(TAG).i("REPO-CRITICAL: Got server from settings: $serverAddress") - if (serverAddress.isBlank()) { - Timber.tag(TAG).e("Server address is blank. Using Waliot by default...") - serverAddress = "device.waliot.com:30032" - } - - val parts = serverAddress.split(":") - val host = parts.getOrNull(0) ?: DEFAULT_HOST - val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_PORT - - try { - Socket(host, port).use { socket -> - // Login Packet - val loginMessage = "#L#$PROTOCOL_VERSION;$username;$DEFAULT_PASSWORD" - val loginBytes = loginMessage.toByteArray(Charsets.UTF_8) - val loginCrc = toHex(calculate(loginBytes)) - val fullLoginMessage = "$loginMessage;$loginCrc\r\n" - - socket.getOutputStream().write(fullLoginMessage.toByteArray(Charsets.UTF_8)) - - val loginResponse = socket.getInputStream().bufferedReader().readLine() - if (!loginResponse.startsWith("#AL#1")) { - Timber.tag(TAG).e("Login failed: $loginResponse") - return@withContext false - } - - // Extended Data Packet - val date = dateFormatter.format(location.time) - val time = timeFormatter.format(location.time) - val lat1 = decimalToDms(location.latitude, false) - val lat2 = latitudeToHemisphere(location.latitude) - val lon1 = decimalToDms(location.longitude, true) - val lon2 = longitudeToHemisphere(location.longitude) - val speed = (location.speed * 3.6).roundToInt() - val course = location.bearing.roundToInt() - val alt = location.altitude.roundToInt() - val sats = location.extras?.getInt("satellites")?.takeIf { it != 0 }?.toString() - ?: NO_VALUE - val hdop = location.extras?.getDouble("hdop")?.takeIf { it != 0.0 }?.toString() - ?: NO_VALUE - val inputs = NO_VALUE - val outputs = NO_VALUE - val adc = NO_VALUE - val iButton = NO_VALUE - val params = "accuracy:2:${location.accuracy},provider:3:${location.provider}" - - val dataPayload = listOf( - date, time, lat1, lat2, lon1, lon2, speed, course, alt, sats, hdop, - inputs, outputs, adc, iButton, params - ).joinToString(";") - - val dataMessage = "#D#$dataPayload" - val dataBytes = dataMessage.toByteArray(Charsets.UTF_8) - val dataCrc = toHex(calculate(dataBytes)) - val fullDataMessage = "$dataMessage;$dataCrc\r\n" - - socket.getOutputStream().write(fullDataMessage.toByteArray(Charsets.UTF_8)) - - val dataResponse = socket.getInputStream().bufferedReader().readLine() - if (!dataResponse.startsWith("#AD#1")) { - Timber.tag(TAG).e("Upload failed: $dataResponse") - return@withContext false - } - - success = true - } - } catch (e: Exception) { - Timber.tag(TAG).e(e, "REPO-CRITICAL: Socket connection failed") - return@withContext false - } - - Timber.tag(TAG).i("REPO-CRITICAL: Location upload process is done successfully") - - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Unhandled exception during upload") - errorMessage = e.localizedMessage ?: "Unknown upload error" - success = false - } finally { - // Update the status flow regardless of outcome - Timber.tag(TAG).d("Finally block: success=$success, errorMessage='$errorMessage'") - _lastUploadStatus.value = - if (success) UploadStatus.Success else UploadStatus.Failure(errorMessage) - } - return@withContext success - } - - /** - * Retrieves the previously saved location from SharedPreferences. - * - * @return The previously saved location, or null if no location was saved - */ - override suspend fun getPreviousLocation(): Location? = withContext(Dispatchers.IO) { - val lat = sharedPreferences.getFloat(KEY_PREVIOUS_LATITUDE, 0f) - val lon = sharedPreferences.getFloat(KEY_PREVIOUS_LONGITUDE, 0f) - - if (lat != 0f && lon != 0f) { - Location("").apply { - latitude = lat.toDouble() - longitude = lon.toDouble() - } - } else { - null - } - } - - /** - * Saves the current location and updates distance calculations. - * - * This method: - * 1. Retrieves the previous location from the state flow - * 2. Calculates the distance increment if there was a previous location - * 3. Updates the total distance state flow - * 4. Updates the latest location state flow - * 5. Persists the current location to SharedPreferences - * - * @param location The new location to save - */ - override suspend fun saveAsPreviousLocation(location: Location) = withContext(Dispatchers.IO) { - val previousLocation = _latestLocation.value - - if (previousLocation != null) { - val distanceIncrement = location.distanceTo(previousLocation) // Distance in meters - _totalDistance.update { it + distanceIncrement } - Timber.d("Distance updated: +${distanceIncrement}m, Total: ${_totalDistance.value}m") - } else { - Timber.d("First location received, distance starts at 0.") - } - - // Update the latest location flow - _latestLocation.value = location - - // Persist coordinates for potential app restart - sharedPreferences.edit().apply { - putFloat(KEY_PREVIOUS_LATITUDE, location.latitude.toFloat()) - putFloat(KEY_PREVIOUS_LONGITUDE, location.longitude.toFloat()) - apply() - } - Timber.tag(TAG) - .d("Updated location state: Lat=${location.latitude}, Lon=${location.longitude}, TotalDist=${_totalDistance.value}m") - } - - /** - * Resets all location state for a new tracking session. - * - * This method: - * 1. Clears the latest location state flow - * 2. Resets the total distance to zero - * 3. Sets the upload status to Idle - * 4. Removes persisted location data from SharedPreferences - */ - override suspend fun resetLocationState() = withContext(Dispatchers.IO) { - _latestLocation.value = null - _totalDistance.value = 0f - _lastUploadStatus.value = UploadStatus.Idle - sharedPreferences.edit().apply { - remove(KEY_PREVIOUS_LATITUDE) - remove(KEY_PREVIOUS_LONGITUDE) - apply() - } - Timber.i("Location state reset.") - } - - /** - * DMS: degrees minutes seconds - */ - fun decimalToDms(coordinate: Double, isLon: Boolean): String { - val absCoordinate = abs(coordinate) - val degrees = absCoordinate.toInt() - val minutes = (absCoordinate - degrees) * 60.0 - val dmm = degrees * 100 + minutes - - return if (isLon) { - String.format(Locale.US, "%09.5f", dmm) - } else { - String.format(Locale.US, "%08.5f", dmm) - } - } - - fun latitudeToHemisphere(latitude: Double): String { - return if (latitude >= 0) "N" else "S" - } - - fun longitudeToHemisphere(longitude: Double): String { - return if (longitude >= 0) "E" else "W" - } - - /** - * Constants used by this repository implementation - */ - companion object { - private const val TAG = "LocationRepository" - private const val PREFS_NAME = "com.websmithing.gpstracker2.location_prefs" - private const val KEY_PREVIOUS_LATITUDE = "previousLatitude" - private const val KEY_PREVIOUS_LONGITUDE = "previousLongitude" - - private const val DEFAULT_HOST = "device.waliot.com" - private const val DEFAULT_PORT = 30032 - private const val PROTOCOL_VERSION = "2.0" - private const val NO_VALUE = "NA" - private const val DEFAULT_PASSWORD = NO_VALUE - - private val table = intArrayOf( - 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, - 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, - 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, - 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, - 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, - 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, - 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, - 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, - 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, - 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, - 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, - 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, - 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, - 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, - 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, - 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, - 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, - 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, - 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, - 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, - 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, - 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, - 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, - 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, - 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, - 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, - 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, - 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, - 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, - 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, - 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, - 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 - ) - - fun calculate(data: ByteArray): Int { - var crc = 0 - for (b in data) { - val index = (crc xor (b.toInt() and 0xFF)) and 0xFF - crc = (crc shr 8) xor table[index] - } - return crc and 0xFFFF - } - - fun toHex(crc: Int): String { - val high = (crc shr 8) and 0xFF - val low = crc and 0xFF - return "%02X%02X".format(high, low) - } - } -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/AppModule.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/AppModule.kt index dd502af9..505dcf08 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/AppModule.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/AppModule.kt @@ -1,113 +1,30 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/di/AppModule.kt package com.websmithing.gpstracker2.di import android.content.Context import android.content.SharedPreferences import com.google.android.gms.location.FusedLocationProviderClient import com.google.android.gms.location.LocationServices +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.PREFS_NAME import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import okhttp3.logging.HttpLoggingInterceptor -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory -import java.util.concurrent.TimeUnit import javax.inject.Singleton -/** - * Hilt dependency injection module for providing application-level singleton dependencies. - * - * This module uses Dagger Hilt's [Provides] annotation to create and configure - * instances of various dependencies used throughout the application, including: - * - SharedPreferences for persistent storage - * - FusedLocationProviderClient for location services - * - OkHttpClient and Retrofit for networking - * - * All dependencies provided by this module are scoped as singletons, meaning they - * will be created once and reused throughout the application's lifecycle. - */ @Module @InstallIn(SingletonComponent::class) object AppModule { - // --- SharedPreferences --- - /** - * Name for the application's SharedPreferences file - */ - private const val PREFS_NAME = "com.websmithing.gpstracker2.prefs" - - /** - * Provides a singleton instance of SharedPreferences. - * - * @param context The application context - * @return A SharedPreferences instance for persistent storage - */ - @Provides - @Singleton - fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { - return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - } - - // --- Location --- - /** - * Provides a singleton instance of FusedLocationProviderClient. - * - * FusedLocationProviderClient is the main entry point for interacting with the - * Google Play Services location APIs. - * - * @param context The application context - * @return A FusedLocationProviderClient instance for location services - */ - @Provides - @Singleton - fun provideFusedLocationProviderClient(@ApplicationContext context: Context): FusedLocationProviderClient { - return LocationServices.getFusedLocationProviderClient(context) - } - - // --- Network --- - /** - * Provides a singleton instance of OkHttpClient. - * - * Configures the HTTP client with logging interceptors and timeouts. - * Longer timeouts are used to accommodate potential network issues when - * uploading location data from areas with poor connectivity. - * - * @return A configured OkHttpClient instance - */ @Provides @Singleton - fun provideOkHttpClient(): OkHttpClient { - val logging = HttpLoggingInterceptor().apply { - // TODO: Set level based on BuildConfig.DEBUG later - level = HttpLoggingInterceptor.Level.BODY - } - return OkHttpClient.Builder() - .addInterceptor(logging) - .connectTimeout(60, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .writeTimeout(60, TimeUnit.SECONDS) - .build() - } + fun provideSharedPreferences( + @ApplicationContext ctx: Context + ): SharedPreferences = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - /** - * Provides a singleton instance of Retrofit.Builder. - * - * Unlike a typical Retrofit configuration, this provides a builder without - * setting a base URL. This allows the repository to dynamically set the base URL - * at runtime based on user settings. - * - * @param okHttpClient The OkHttpClient to use for HTTP requests - * @return A Retrofit.Builder instance configured with the OkHttpClient and converter factory - */ @Provides @Singleton - fun provideRetrofitBuilder(okHttpClient: OkHttpClient): Retrofit.Builder { - return Retrofit.Builder() - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) - // No base URL here, it will be set dynamically in the repository - } -} \ No newline at end of file + fun provideFusedLocationProviderClient( + @ApplicationContext ctx: Context + ): FusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(ctx) +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/BindsUtilModule.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/BindsUtilModule.kt deleted file mode 100644 index a793228e..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/BindsUtilModule.kt +++ /dev/null @@ -1,42 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/di/BindsUtilModule.kt -package com.websmithing.gpstracker2.di - -import com.websmithing.gpstracker2.util.PermissionChecker -import com.websmithing.gpstracker2.util.PermissionCheckerImpl -import dagger.Binds -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** - * Hilt dependency injection module for binding interface implementations using the @Binds annotation. - * - * This module demonstrates the use of Dagger's @Binds annotation, which is more efficient - * than @Provides for binding an implementation to an interface. Unlike @Provides, which - * requires an explicit instance creation, @Binds simply tells Dagger how to map an existing - * binding to another type (typically an interface to its implementation). - * - * The module is abstract because @Binds methods are abstract and don't contain implementation code. - */ -@Module -@InstallIn(SingletonComponent::class) -abstract class BindsUtilModule { - - /** - * Binds the PermissionCheckerImpl implementation to the PermissionChecker interface. - * - * This allows Dagger to provide an instance of PermissionChecker when it's requested - * for injection, by automatically using the PermissionCheckerImpl implementation. - * This binding is scoped as a singleton to ensure consistent permission checking - * throughout the application. - * - * @param permissionCheckerImpl The implementation of PermissionChecker to bind - * @return The bound PermissionChecker interface - */ - @Binds - @Singleton - abstract fun bindPermissionChecker( - permissionCheckerImpl: PermissionCheckerImpl - ): PermissionChecker -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/ProvidesUtilModule.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/ProvidesUtilModule.kt deleted file mode 100644 index d5c0478f..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/ProvidesUtilModule.kt +++ /dev/null @@ -1,21 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/di/ProvidesUtilModule.kt // Renamed file -package com.websmithing.gpstracker2.di - -// Removed WorkManager import -// Removed WorkerScheduler import -// Removed WorkerSchedulerImpl import -// Removed PermissionChecker imports -// Removed Binds import -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -// Change back to object as we only have @Provides -object ProvidesUtilModule { // Renamed class - - // WorkerScheduler and WorkManager providers removed as they are no longer needed. - - // Removed @Binds method for PermissionChecker (it's in BindsUtilModule) -} \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/RepositoryModule.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/RepositoryModule.kt index 7657fe8c..1de491f6 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/RepositoryModule.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/RepositoryModule.kt @@ -1,95 +1,30 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/di/RepositoryModule.kt package com.websmithing.gpstracker2.di -import android.content.Context -import android.content.SharedPreferences -import com.google.android.gms.location.FusedLocationProviderClient -import com.websmithing.gpstracker2.data.repository.ForegroundLocationRepository -import com.websmithing.gpstracker2.data.repository.ForegroundLocationRepositoryImpl -import com.websmithing.gpstracker2.data.repository.LocationRepository -import com.websmithing.gpstracker2.data.repository.SettingsRepository -import com.websmithing.gpstracker2.data.repository.SettingsRepositoryImpl -import com.websmithing.gpstracker2.data.repository.WialonIpsLocationRepositoryImpl -import com.websmithing.gpstracker2.util.PermissionChecker +import com.websmithing.gpstracker2.repository.location.LocationRepository +import com.websmithing.gpstracker2.repository.location.LocationRepositoryImpl +import com.websmithing.gpstracker2.repository.settings.SettingsRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepositoryImpl +import com.websmithing.gpstracker2.repository.upload.UploadRepository +import com.websmithing.gpstracker2.repository.upload.UploadRepositoryImpl +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import okhttp3.OkHttpClient -import retrofit2.Retrofit import javax.inject.Singleton -/** - * Hilt dependency injection module for providing repository dependencies. - * - * This module provides implementations of the application's repository interfaces, - * which serve as the data layer of the application. The repositories abstract - * the data sources (local storage, network, etc.) from the rest of the application. - * - * All repositories are provided as singletons to ensure consistent data access - * throughout the application. - */ @Module @InstallIn(SingletonComponent::class) -object RepositoryModule { +abstract class RepositoryModule { - /** - * Provides a singleton implementation of the SettingsRepository interface. - * - * The SettingsRepository handles user preferences and app settings, - * persisting them using SharedPreferences. - * - * @param sharedPreferences The SharedPreferences instance for storing settings - * @return An implementation of SettingsRepository - */ - @Provides + @Binds @Singleton - fun provideSettingsRepository(sharedPreferences: SharedPreferences): SettingsRepository { - return SettingsRepositoryImpl(sharedPreferences) - } + abstract fun bindSettingsRepository(impl: SettingsRepositoryImpl): SettingsRepository - /** - * Provides a singleton implementation of the LocationRepository interface. - * - * The LocationRepository handles location data operations: - * - Fetching the current location - * - Calculating distance traveled - * - Uploading location data to the server - * - Managing location state - * - * @param context The application context - * @param fusedLocationProviderClient Client for accessing location services - * @param okHttpClient HTTP client for network requests - * @param retrofitBuilder Builder for creating Retrofit instances with dynamic base URLs - * @param settingsRepository Repository for accessing user settings - * @param permissionChecker Utility for checking location permissions - * @return An implementation of LocationRepository - */ - @Provides + @Binds @Singleton - fun provideLocationRepository( - @ApplicationContext context: Context, - fusedLocationProviderClient: FusedLocationProviderClient, - okHttpClient: OkHttpClient, - retrofitBuilder: Retrofit.Builder, - settingsRepository: SettingsRepository, - permissionChecker: PermissionChecker - ): LocationRepository { - return WialonIpsLocationRepositoryImpl( - context, - fusedLocationProviderClient, - settingsRepository, - permissionChecker - ) - } + abstract fun bindLocationRepository(impl: LocationRepositoryImpl): LocationRepository - @Provides - fun provideForegroundLocationRepository( - fusedLocationProviderClient: FusedLocationProviderClient, - ): ForegroundLocationRepository { - return ForegroundLocationRepositoryImpl( - provider = fusedLocationProviderClient - ) - } + @Binds + @Singleton + abstract fun bindUploadRepository(impl: UploadRepositoryImpl): UploadRepository } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/SettingsRepositoryEntryPoint.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/SettingsRepositoryEntryPoint.kt index a0d37068..0df6ea0e 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/SettingsRepositoryEntryPoint.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/SettingsRepositoryEntryPoint.kt @@ -1,6 +1,6 @@ package com.websmithing.gpstracker2.di -import com.websmithing.gpstracker2.data.repository.SettingsRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepository import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent @@ -8,5 +8,5 @@ import dagger.hilt.components.SingletonComponent @EntryPoint @InstallIn(SingletonComponent::class) interface SettingsRepositoryEntryPoint { - fun getSettingsRepository(): SettingsRepository -} \ No newline at end of file + fun settingsRepository(): SettingsRepository +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/UtilModule.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/UtilModule.kt deleted file mode 100644 index 3f288b8b..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/di/UtilModule.kt +++ /dev/null @@ -1,2 +0,0 @@ -// This file is intentionally left empty to resolve build conflicts. -// Bindings are now in BindsUtilModule.kt and ProvidesUtilModule.kt. \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/helper/LocaleHelper.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/helper/LocaleHelper.kt new file mode 100644 index 00000000..11cf6e02 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/helper/LocaleHelper.kt @@ -0,0 +1,27 @@ +package com.websmithing.gpstracker2.helper + +import android.content.Context +import android.content.res.Configuration +import com.websmithing.gpstracker2.repository.settings.SettingsRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_LANGUAGE +import java.util.Locale + +object LocaleHelper { + + suspend fun wrapContext(ctx: Context, settingsRepository: SettingsRepository): Context { + val languageCode = try { + settingsRepository.getLanguage() + } catch (_: Exception) { + DEFAULT_LANGUAGE + } + + val locale = Locale.forLanguageTag(languageCode) + Locale.setDefault(locale) + + val config = Configuration(ctx.resources.configuration).apply { + setLocale(locale) + } + + return ctx.createConfigurationContext(config) + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/network/ApiService.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/network/ApiService.kt deleted file mode 100644 index e4030064..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/network/ApiService.kt +++ /dev/null @@ -1,100 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/network/ApiService.kt -package com.websmithing.gpstracker2.network - -import retrofit2.Response -import retrofit2.http.Field -import retrofit2.http.FormUrlEncoded -import retrofit2.http.POST - -/** - * Retrofit API interface for the GPS Tracker backend. - * - * This interface defines the network endpoints for communicating with the GPS Tracker - * server. It uses Retrofit annotations to specify the HTTP methods, endpoints, and - * parameter formatting. - * - * All methods are suspending functions, designed to be called from coroutines. - */ -interface ApiService { - - /** - * Sends location data to the server. - * - * This method uploads the device's location information to the GPS Tracker server - * using HTTP POST with form URL encoding. The server expects a specific set of - * parameters with the location details. - * - * Endpoint: https://www.websmithing.com/gpstracker/api/locations/update - * Content-Type: application/x-www-form-urlencoded - * - * @param latitude The device's latitude coordinate as a string - * @param longitude The device's longitude coordinate as a string - * @param speed The device's speed in miles per hour - * @param direction The device's bearing/direction in degrees (0-359) - * @param date The date and time of the location reading in "YYYY-MM-DD HH:MM:SS" format (URL-encoded) - * @param locationMethod The location provider method (e.g., "gps", "network") (URL-encoded) - * @param username The username identifying this tracker - * @param phoneNumber The unique identifier for this device (using app ID UUID) - * @param sessionId The UUID for the current tracking session - * @param accuracy The location accuracy in meters - * @param extraInfo Additional information (typically used for altitude in meters) - * @param eventType The type of event triggering this update (e.g., "periodic-android") - * @return A Response containing a String. Success returns the database ID or timestamp, failure returns "-1" - */ - @FormUrlEncoded - @POST("update") - suspend fun updateLocation( - @Field("latitude") latitude: String, - @Field("longitude") longitude: String, - @Field("speed") speed: Int, - @Field("direction") direction: Int, - @Field("date") date: String, - @Field("locationmethod") locationMethod: String, - @Field("username") username: String, - @Field("phonenumber") phoneNumber: String, - @Field("sessionid") sessionId: String, - @Field("accuracy") accuracy: Int, - @Field("extrainfo") extraInfo: String, - @Field("eventtype") eventType: String - ): Response - - /** - * Simple test method with minimal required parameters for debugging. - * - * This method provides a simplified version of the updateLocation method - * with default values for most parameters, making it easier to test the - * server connection. - * - * @param latitude The device's latitude coordinate as a string - * @param longitude The device's longitude coordinate as a string - * @param speed The device's speed (default: 0) - * @param direction The device's bearing/direction (default: 0) - * @param distance The distance traveled in this session (default: "0.0") - * @param date The date and time (default: "2023-04-05 00:00:00") - * @param locationMethod The location provider method (default: "test") - * @param username The username identifying this tracker - * @param phoneNumber The device identifier (default: "test_phone") - * @param sessionId The UUID for the current tracking session - * @param accuracy The location accuracy (default: 0) - * @param extraInfo Additional information (default: "test") - * @param eventType The type of event (default: "test-direct") - * @return A Response containing a String, similar to updateLocation - */ - @FormUrlEncoded - @POST("update") - suspend fun testUpdate( - @Field("latitude") latitude: String, - @Field("longitude") longitude: String, - @Field("speed") speed: Int = 0, - @Field("direction") direction: Int = 0, - @Field("distance") distance: String = "0.0", - @Field("date") date: String = "2023-04-05 00:00:00", - @Field("locationmethod") locationMethod: String = "test", - @Field("username") username: String, - @Field("phonenumber") phoneNumber: String = "test_phone", - @Field("sessionid") sessionId: String, - @Field("accuracy") accuracy: Int = 0, - @Field("extrainfo") extraInfo: String = "test", - @Field("eventtype") eventType: String = "test-direct" - ): Response -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepository.kt new file mode 100644 index 00000000..62f3e549 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepository.kt @@ -0,0 +1,10 @@ +package com.websmithing.gpstracker2.repository.location + +import android.location.Location +import kotlinx.coroutines.flow.Flow + +interface LocationRepository { + val currentLocation: Flow + fun start() + fun stop() +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepositoryImpl.kt new file mode 100644 index 00000000..203448ca --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/location/LocationRepositoryImpl.kt @@ -0,0 +1,93 @@ +package com.websmithing.gpstracker2.repository.location + +import android.annotation.SuppressLint +import android.location.Location +import android.os.Looper +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocationRepositoryImpl @Inject constructor( + private val provider: FusedLocationProviderClient, +) : LocationRepository { + + private val _currentLocation = MutableStateFlow(null) + override val currentLocation: StateFlow = _currentLocation + + private var locationCallback: LocationCallback? = null + private var initialFixReceived = false + + private companion object { + const val TAG = "LocationRepository" + } + + override fun start() { + if (locationCallback != null) return + + locationCallback = object : LocationCallback() { + override fun onLocationResult(result: LocationResult) { + result.lastLocation?.let { loc -> + updateLocationState(loc, "Callback fix") + } + } + } + + requestInitialFix() + startLocationUpdates() + } + + override fun stop() { + locationCallback?.let { + provider.removeLocationUpdates(it) + Timber.tag(TAG).d("Location updates stopped") + } + locationCallback = null + } + + private fun updateLocationState(location: Location, source: String) { + initialFixReceived = true + _currentLocation.value = location + Timber.tag(TAG).i("$source: ${location.latitude}, ${location.longitude}") + } + + @SuppressLint("MissingPermission") + private fun requestInitialFix() { + provider.lastLocation.addOnSuccessListener { loc -> + loc?.let { updateLocationState(it, "Initial fix from lastLocation") } + } + + provider.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) + .addOnSuccessListener { loc -> + if (!initialFixReceived && loc != null) { + updateLocationState(loc, "Initial fix from getCurrentLocation") + } + } + } + + @SuppressLint("MissingPermission") + private fun startLocationUpdates() { + val request = createLocationRequest() + locationCallback?.let { cb -> + provider.requestLocationUpdates(request, cb, Looper.getMainLooper()) + Timber.tag(TAG).d("Location updates started") + } + } + + private fun createLocationRequest(): LocationRequest { + val interval = 10_000L + return LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, interval) + .setMinUpdateIntervalMillis(interval) + .setMaxUpdateDelayMillis(interval) + .setMinUpdateDistanceMeters(0f) + .setWaitForAccurateLocation(true) + .build() + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepository.kt new file mode 100644 index 00000000..b8ee62c8 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepository.kt @@ -0,0 +1,52 @@ +package com.websmithing.gpstracker2.repository.settings + +interface SettingsRepository { + + companion object { + const val PREFS_NAME = "com.waliot.tracker" + + const val KEY_APP_ID = "appID" + + const val KEY_TRACKING_STATE = "trackingState" + const val DEFAULT_TRACKING_STATE = false + + const val KEY_TRACKER_IDENTIFIER = "trackerIdentifier" + const val DEFAULT_TRACKER_IDENTIFIER = "" + + const val KEY_UPLOAD_SERVER = "uploadServer" + const val DEFAULT_UPLOAD_SERVER = "device.waliot.com:30032" + + const val KEY_UPLOAD_TIME_INTERVAL = "uploadTimeInterval" + const val DEFAULT_UPLOAD_TIME_INTERVAL = 5 + + const val KEY_UPLOAD_DISTANCE_INTERVAL = "uploadDistanceInterval" + const val DEFAULT_UPLOAD_DISTANCE_INTERVAL = 100 + + const val KEY_LANGUAGE = "language" + const val DEFAULT_LANGUAGE = "ru" + } + + suspend fun getAppId(): String + suspend fun generateAndSaveAppId(): String + + suspend fun getTrackingState(): Boolean + suspend fun setTrackingState(isTracking: Boolean) + + suspend fun getTrackerIdentifier(): String + suspend fun setTrackingIdentifier(trackerIdentifier: String) + + suspend fun getUploadServer(): String + suspend fun setUploadServer(serverAddress: String) + + suspend fun getUploadTimeInterval(): Int + suspend fun setUploadTimeInterval(intervalMinutes: Int) + + suspend fun getUploadDistanceInterval(): Int + suspend fun setUploadDistanceInterval(intervalMeters: Int) + + suspend fun getLanguage(): String + suspend fun setLanguage(language: String) + + suspend fun isFirstTimeLoading(): Boolean + suspend fun setFirstTimeLoading(isFirst: Boolean) +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepositoryImpl.kt new file mode 100644 index 00000000..d9b0b21a --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/settings/SettingsRepositoryImpl.kt @@ -0,0 +1,97 @@ +package com.websmithing.gpstracker2.repository.settings + +import android.content.SharedPreferences +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_LANGUAGE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_TRACKER_IDENTIFIER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_TRACKING_STATE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_DISTANCE_INTERVAL +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_SERVER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_TIME_INTERVAL +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_APP_ID +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_LANGUAGE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_TRACKER_IDENTIFIER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_TRACKING_STATE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_UPLOAD_DISTANCE_INTERVAL +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_UPLOAD_SERVER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.KEY_UPLOAD_TIME_INTERVAL +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class SettingsRepositoryImpl @Inject constructor( + private val sharedPreferences: SharedPreferences +) : SettingsRepository { + + override suspend fun getAppId(): String = withContext(Dispatchers.IO) { + sharedPreferences.getString(KEY_APP_ID, null) ?: generateAndSaveAppId() + } + + override suspend fun generateAndSaveAppId(): String = withContext(Dispatchers.IO) { + UUID.randomUUID().toString().also { newId -> + updateSettings { putString(KEY_APP_ID, newId) } + } + } + + override suspend fun getTrackingState(): Boolean = withContext(Dispatchers.IO) { + sharedPreferences.getBoolean(KEY_TRACKING_STATE, DEFAULT_TRACKING_STATE) + } + + override suspend fun setTrackingState(isTracking: Boolean) = updateSettings { + putBoolean(KEY_TRACKING_STATE, isTracking) + } + + override suspend fun getTrackerIdentifier(): String = getStringOrDefault(KEY_TRACKER_IDENTIFIER, DEFAULT_TRACKER_IDENTIFIER) + + override suspend fun setTrackingIdentifier(trackerIdentifier: String) = updateSettings { + putString(KEY_TRACKER_IDENTIFIER, trackerIdentifier.trim()) + } + + override suspend fun getUploadServer(): String = getStringOrDefault(KEY_UPLOAD_SERVER, DEFAULT_UPLOAD_SERVER) + + override suspend fun setUploadServer(serverAddress: String) = updateSettings { + putString(KEY_UPLOAD_SERVER, serverAddress.trim()) + } + + override suspend fun getUploadTimeInterval(): Int = withContext(Dispatchers.IO) { + sharedPreferences.getInt(KEY_UPLOAD_TIME_INTERVAL, DEFAULT_UPLOAD_TIME_INTERVAL) + } + + override suspend fun setUploadTimeInterval(intervalMinutes: Int) = updateSettings { + putInt(KEY_UPLOAD_TIME_INTERVAL, intervalMinutes) + } + + override suspend fun getUploadDistanceInterval(): Int = withContext(Dispatchers.IO) { + sharedPreferences.getInt(KEY_UPLOAD_DISTANCE_INTERVAL, DEFAULT_UPLOAD_DISTANCE_INTERVAL) + } + + override suspend fun setUploadDistanceInterval(intervalMeters: Int) = updateSettings { + putInt(KEY_UPLOAD_DISTANCE_INTERVAL, intervalMeters) + } + + override suspend fun getLanguage(): String = getStringOrDefault(KEY_LANGUAGE, DEFAULT_LANGUAGE) + + override suspend fun setLanguage(language: String) = updateSettings { + putString(KEY_LANGUAGE, language.trim()) + } + + override suspend fun isFirstTimeLoading(): Boolean = withContext(Dispatchers.IO) { + !sharedPreferences.contains(KEY_APP_ID) + } + + override suspend fun setFirstTimeLoading(isFirst: Boolean) { + if (!isFirst) getAppId() + } + + private suspend fun getStringOrDefault(key: String, default: String): String = withContext(Dispatchers.IO) { + sharedPreferences.getString(key, default) ?: default + } + + private suspend fun updateSettings(block: SharedPreferences.Editor.() -> Unit) { + withContext(Dispatchers.IO) { + sharedPreferences.edit().apply(block).apply() + } + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepository.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepository.kt new file mode 100644 index 00000000..2ef12fbb --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepository.kt @@ -0,0 +1,17 @@ +package com.websmithing.gpstracker2.repository.upload + +import android.location.Location +import kotlinx.coroutines.flow.Flow + +interface UploadRepository { + val lastUploadStatus: Flow + + suspend fun resetUploadStatus() + suspend fun uploadData(trackerIdentifier: String, location: Location): Boolean +} + +sealed class UploadStatus { + object Idle : UploadStatus() + object Success : UploadStatus() + data class Failure(val errorMessage: String?) : UploadStatus() +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepositoryImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepositoryImpl.kt new file mode 100644 index 00000000..fe4d79a6 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/repository/upload/UploadRepositoryImpl.kt @@ -0,0 +1,143 @@ +package com.websmithing.gpstracker2.repository.upload + +import android.location.Location +import com.websmithing.gpstracker2.repository.settings.SettingsRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_SERVER +import com.websmithing.gpstracker2.util.CrcUtils +import com.websmithing.gpstracker2.util.NmeaUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import timber.log.Timber +import java.net.InetSocketAddress +import java.net.Socket +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.roundToInt + +@Singleton +class UploadRepositoryImpl @Inject constructor( + private val settingsRepository: SettingsRepository +) : UploadRepository { + + private val _lastUploadStatus = MutableStateFlow(UploadStatus.Idle) + override val lastUploadStatus: StateFlow = _lastUploadStatus.asStateFlow() + + private val dateFormatter = SimpleDateFormat("ddMMyy", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + private val timeFormatter = SimpleDateFormat("HHmmss", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + private companion object { + const val TAG = "UploadRepository" + + const val DEFAULT_HOST = "device.waliot.com" + const val DEFAULT_PORT = 30032 + const val CONNECT_TIMEOUT = 5_000 + const val SEND_TIMEOUT = 30_000 + + const val PROTOCOL_VERSION = "2.0" + const val NO_VALUE = "NA" + const val DEFAULT_PASSWORD = NO_VALUE + } + + override suspend fun resetUploadStatus() = withContext(Dispatchers.IO) { + _lastUploadStatus.value = UploadStatus.Idle + } + + override suspend fun uploadData(trackerIdentifier: String, location: Location): Boolean = withContext(Dispatchers.IO) { + var success = false + var errorMessage: String? = null + try { + Timber.tag(TAG).i("\uD83C\uDF00 Starting location upload process...") + val (host, port) = getServerAddress() + + Socket().use { socket -> + socket.connect(InetSocketAddress(host, port), CONNECT_TIMEOUT) + socket.soTimeout = SEND_TIMEOUT + + val output = socket.getOutputStream() + val input = socket.getInputStream().bufferedReader() + + // Login + val loginPacket = createPacket("#L#$PROTOCOL_VERSION;$trackerIdentifier;$DEFAULT_PASSWORD") + Timber.tag(TAG).d("Sending login packet: $loginPacket") + output.write(loginPacket.toByteArray(Charsets.UTF_8)) + output.flush() + + val loginResponse = input.readLine() + Timber.tag(TAG).d("Login response: $loginResponse") + if (loginResponse?.startsWith("#AL#1") != true) { + throw IllegalStateException("Login failed: $loginResponse") + } + + // Data + val dataPayload = buildPayload(location) + val dataPacket = createPacket("#D#$dataPayload") + Timber.tag(TAG).d("Sending data packet: $dataPacket") + output.write(dataPacket.toByteArray(Charsets.UTF_8)) + output.flush() + + val dataResponse = input.readLine() + Timber.tag(TAG).d("Data response: $dataResponse") + if (dataResponse?.startsWith("#AD#1") != true) { + throw IllegalStateException("Upload failed: $dataResponse") + } + + success = true + } + Timber.tag(TAG).i("✅ Location upload process is done successfully!") + + } catch (e: Exception) { + Timber.tag(TAG).e(e, "❌ Upload process error") + errorMessage = e.localizedMessage ?: "—" + success = false + + } finally { + Timber.tag(TAG).d("Finally block: success=$success, errorMessage='$errorMessage'") + _lastUploadStatus.value = if (success) UploadStatus.Success else UploadStatus.Failure(errorMessage) + } + + success + } + + private suspend fun getServerAddress(): Pair { + var serverAddress = settingsRepository.getUploadServer() + if (serverAddress.isBlank()) { + Timber.tag(TAG).w("Server address is blank. Using default...") + serverAddress = DEFAULT_UPLOAD_SERVER + } + val parts = serverAddress.split(":") + val host = parts.getOrNull(0) ?: DEFAULT_HOST + val port = parts.getOrNull(1)?.toIntOrNull() ?: DEFAULT_PORT + return host to port + } + + private fun createPacket(message: String): String { + val crc = CrcUtils.formatCrcToHex(CrcUtils.calculateCrc16(message.toByteArray(Charsets.UTF_8))) + return "$message;$crc\r\n" + } + + private fun buildPayload(location: Location): String { + return listOf( + dateFormatter.format(location.time), + timeFormatter.format(location.time), + NmeaUtils.latitudeToDdm(location.latitude, ";"), + NmeaUtils.longitudeToDdm(location.longitude, ";"), + (location.speed * 3.6).roundToInt(), + location.bearing.roundToInt(), + location.altitude.roundToInt(), + location.extras?.getInt("satellites")?.takeIf { it != 0 } ?: NO_VALUE, + location.extras?.getDouble("hdop")?.takeIf { it != 0.0 } ?: NO_VALUE, + NO_VALUE, NO_VALUE, NO_VALUE, NO_VALUE, // inputs, outputs, adc, iButton + "accuracy:2:${location.accuracy},provider:3:${location.provider}" + ).joinToString(";") + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/service/TrackingService.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/service/TrackingService.kt index d820be1c..cc32e070 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/service/TrackingService.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/service/TrackingService.kt @@ -1,7 +1,5 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/service/TrackingService.kt package com.websmithing.gpstracker2.service -import android.annotation.SuppressLint import android.app.AlarmManager import android.app.Notification import android.app.NotificationChannel @@ -11,552 +9,293 @@ import android.app.Service import android.content.Context import android.content.Intent import android.location.Location -import android.os.Build import android.os.IBinder -import android.os.Looper import android.os.PowerManager import android.os.SystemClock import androidx.core.app.NotificationCompat -import com.google.android.gms.location.FusedLocationProviderClient -import com.google.android.gms.location.LocationCallback -import com.google.android.gms.location.LocationRequest -import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.Priority import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.data.repository.LocationRepository -import com.websmithing.gpstracker2.data.repository.SettingsRepository import com.websmithing.gpstracker2.di.SettingsRepositoryEntryPoint -import com.websmithing.gpstracker2.util.LocaleHelper +import com.websmithing.gpstracker2.helper.LocaleHelper +import com.websmithing.gpstracker2.repository.location.LocationRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepository +import com.websmithing.gpstracker2.repository.upload.UploadRepository import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import okhttp3.OkHttpClient -import retrofit2.Retrofit -import retrofit2.converter.gson.GsonConverterFactory +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors import java.util.concurrent.TimeUnit import javax.inject.Inject -/** - * Foreground service responsible for location tracking. - * - * This service handles: - * - Starting and stopping location updates via FusedLocationProviderClient - * - Processing location data in a background thread - * - Uploading location data to the remote server - * - Managing wake locks to ensure tracking continues even when the device is in doze mode - * - Displaying a persistent notification to inform the user of active tracking - * - Maintaining the service across app termination and device reboots - * - * The service is integrated with Hilt for dependency injection and uses a combination - * of coroutines (for repository operations) and a single-thread executor for background tasks. - */ @AndroidEntryPoint class TrackingService : Service() { - /** - * Location provider client for requesting location updates - */ @Inject - lateinit var fusedLocationProviderClient: FusedLocationProviderClient + lateinit var settingsRepository: SettingsRepository - /** - * Repository for managing location data operations - */ @Inject lateinit var locationRepository: LocationRepository - /** - * Repository for managing app settings - */ - @Inject - lateinit var settingsRepository: SettingsRepository - - /** - * HTTP client for network operations - */ @Inject - lateinit var okHttpClient: OkHttpClient + lateinit var uploadRepository: UploadRepository - /** - * Callback for receiving location updates - */ - private var locationCallback: LocationCallback? = null + companion object { + private const val TAG = "TrackingService" + private const val WAKE_LOCK_TAG = "WaliotTracker::TrackingServiceWakeLock" - /** - * Executor for running location processing tasks in the background - */ - private var backgroundExecutor: ExecutorService? = null + private const val NOTIFICATION_CHANNEL_ID = "tracking_channel" + private const val NOTIFICATION_CHANNEL_NAME = "Waliot Tracker" + private const val NOTIFICATION_ID = 1 - /** - * Wake lock to keep CPU running during tracking - */ - private var wakeLock: PowerManager.WakeLock? = null + private const val RESTART_DELAY_MS = 5_000 + private const val RESTART_REQUEST_CODE = 1 - /** - * Constants used by the service - */ - companion object { - /** - * Intent action to start the service - */ const val ACTION_START_SERVICE = "ACTION_START_SERVICE" - - /** - * Intent action to stop the service - */ const val ACTION_STOP_SERVICE = "ACTION_STOP_SERVICE" - /** - * ID for the notification channel - */ - private const val NOTIFICATION_CHANNEL_ID = "tracking_channel" + private val _bufferCount = MutableStateFlow(0) + val bufferCount: StateFlow = _bufferCount + } - /** - * Name for the notification channel - */ - private const val NOTIFICATION_CHANNEL_NAME = "GPS Tracking" + private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var wakeLock: PowerManager.WakeLock? = null - /** - * ID for the service notification - */ - private const val NOTIFICATION_ID = 1 - } + private val bufferMutex = Mutex() + private val uploadMutex = Mutex() - override fun attachBaseContext(base: Context) { - val entryPoint = EntryPointAccessors.fromApplication( - base.applicationContext, - SettingsRepositoryEntryPoint::class.java - ) - val repo = entryPoint.getSettingsRepository() - val newCtx = LocaleHelper.onAttach(base, repo) + private val locationBuffer = mutableListOf() + private var lastBufferLocation: Location? = null + + override fun onBind(intent: Intent?): IBinder? = null + + override fun attachBaseContext(newBase: Context) { + val repo = getSettingsRepository(newBase) + val newCtx = runBlocking { + LocaleHelper.wrapContext(newBase, repo) + } super.attachBaseContext(newCtx) } - /** - * Called when the service is first created. - * - * Initializes the notification channel, wake lock, and background executor. - */ + private fun getSettingsRepository(context: Context) = EntryPointAccessors.fromApplication( + context.applicationContext, + SettingsRepositoryEntryPoint::class.java + ).settingsRepository() + override fun onCreate() { super.onCreate() - Timber.d("TrackingService onCreate") - createNotificationChannel() - createWakeLock() - // Initialize the background executor - backgroundExecutor = Executors.newSingleThreadExecutor() - Timber.d("Background executor initialized.") - - // Add a direct test to check connectivity - Thread { - try { - Timber.i("DIRECT-TEST: Starting direct test of connectivity in onCreate") - val testRetrofit = Retrofit.Builder() - .baseUrl("https://www.google.com/") - .addConverterFactory(GsonConverterFactory.create()) - .client( - OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build() - ) - .build() - - val okHttpClient = OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build() - - val request = okhttp3.Request.Builder() - .url("https://www.google.com") - .build() - - try { - val response = okHttpClient.newCall(request).execute() - Timber.i("DIRECT-TEST: Direct HTTP request to Google completed with code: ${response.code}") - } catch (e: Exception) { - Timber.e(e, "DIRECT-TEST: Failed to make direct HTTP request to Google") - } - } catch (e: Exception) { - Timber.e(e, "DIRECT-TEST: Exception in direct test") - } - }.start() + setupWakeLock() + setupNotificationChannel() } - /** - * Creates a partial wake lock to keep the CPU running during tracking - */ - private fun createWakeLock() { - val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager - wakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "GpsTracker::LocationTrackingWakeLock" - ).apply { - setReferenceCounted(false) - } - Timber.d("Wake lock created") + override fun onDestroy() { + super.onDestroy() + releaseWakeLock() + serviceScope.cancel() + locationRepository.stop() } - /** - * Called every time an intent is sent to the service. - * - * Handles service start/stop requests and manages the foreground state. - * - * @param intent The intent sent to the service - * @param flags Additional data about this start request - * @param startId A unique integer representing this specific request to start - * @return [START_STICKY] to indicate that the service should be restarted if killed - */ - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - Timber.d("TrackingService onStartCommand: ${intent?.action}") - when (intent?.action) { - ACTION_START_SERVICE -> { - Timber.d("ACTION_START_SERVICE received") - startForeground(NOTIFICATION_ID, createNotification()) - startLocationUpdates() - } + //region onStartCommand - ACTION_STOP_SERVICE -> { - Timber.d("ACTION_STOP_SERVICE received") - stopLocationUpdates() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val action = intent?.action + Timber.tag(TAG).d("TrackingService onStartCommand: $action") - else -> { - // If service is restarted after being killed, restart location updates - Timber.d("Service restarted without specific action. Re-initializing location updates.") - startForeground(NOTIFICATION_ID, createNotification()) - startLocationUpdates() - } + when (action) { + ACTION_START_SERVICE -> handleStartService() + ACTION_STOP_SERVICE -> handleStopService() + else -> handleDefaultAction() } return START_STICKY } - /** - * Called when the user swipes the app away from recent apps. - * - * Schedules the service to restart after a short delay to ensure continuous tracking. - * - * @param rootIntent The original intent that was used to launch the task that is being removed - */ - override fun onTaskRemoved(rootIntent: Intent?) { - super.onTaskRemoved(rootIntent) - Timber.d("TrackingService onTaskRemoved - application swiped away from recent apps") + private fun handleStartService() { + acquireWakeLock() + startForeground(NOTIFICATION_ID, createNotification()) - // Create a restart intent - val restartServiceIntent = Intent(applicationContext, TrackingService::class.java) - restartServiceIntent.action = ACTION_START_SERVICE - val pIntent = PendingIntent.getService( - applicationContext, 1, restartServiceIntent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE - ) - val alarmManager = - applicationContext.getSystemService(Context.ALARM_SERVICE) as AlarmManager - alarmManager.set( - AlarmManager.ELAPSED_REALTIME, - SystemClock.elapsedRealtime() + 5000, - pIntent - ) + locationRepository.start() - Timber.d("TrackingService scheduled for restart in 5 seconds") - } + serviceScope.launch { + val uploadTimeInterval = settingsRepository.getUploadTimeInterval().toLong() + val uploadDistanceInterval = settingsRepository.getUploadDistanceInterval().toLong() - /** - * Called when the service is being destroyed. - * - * Cleans up resources, stops location updates, and releases the wake lock. - */ - override fun onDestroy() { - super.onDestroy() - Timber.d("TrackingService onDestroy") - // Shut down the executor - backgroundExecutor?.shutdown() - Timber.d("Background executor shutdown requested.") - backgroundExecutor = null - stopLocationUpdates() - - // Make absolutely sure we release the wake lock - wakeLock?.let { - if (it.isHeld) { - it.release() - Timber.d("Wake lock released in onDestroy") + locationRepository.currentLocation.collect { location -> + if (location != null) { + if (shouldAddToBuffer(location, uploadTimeInterval, uploadDistanceInterval)) { + lastBufferLocation = location + + bufferMutex.withLock { + locationBuffer.add(location) + _bufferCount.value = locationBuffer.size + } + + Timber.tag(TAG).i("Location added to buffer. Total: ${_bufferCount.value}") + } + } + } + } + + serviceScope.launch { + val trackerId = settingsRepository.getTrackerIdentifier() + val uploadTimeInterval = settingsRepository.getUploadTimeInterval().toLong() + + while (true) { + delay(TimeUnit.MINUTES.toMillis(uploadTimeInterval)) + uploadBuffer(trackerId) } } - wakeLock = null } - /** - * Not used in this service implementation. - * - * @return Always returns null as this is not a bound service - */ - override fun onBind(intent: Intent?): IBinder? { - return null + private fun handleStopService() { + stopForeground(STOP_FOREGROUND_REMOVE) + locationRepository.stop() + stopSelf() } - /** - * Starts location update requests. - * - * Configures location request parameters based on settings, - * sets up location callbacks, and acquires a wake lock. - */ - @SuppressLint("MissingPermission") - private fun startLocationUpdates() { - Timber.d("Starting location updates...") - - // Acquire wake lock to keep CPU running during updates - wakeLock?.let { - if (!it.isHeld) { - it.acquire(TimeUnit.HOURS.toMillis(10)) // Maximum wake lock time of 10 hours - Timber.d("Wake lock acquired") - } else { - Timber.d("Wake lock already held") - } - } ?: Timber.e("Wake lock is null, cannot acquire") - - try { - // Wrap suspend call with runBlocking - val intervalMinutes = runBlocking { settingsRepository.getCurrentTrackingInterval() } - Timber.d("Using tracking interval: $intervalMinutes minutes") - - val intervalMillis = TimeUnit.MINUTES.toMillis(intervalMinutes.toLong()) - - val locationRequest = - LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, intervalMillis) - .setMinUpdateIntervalMillis(intervalMillis / 2) - .setMaxUpdateDelayMillis(intervalMillis) - .setWaitForAccurateLocation(false) - .build() - - locationCallback = object : LocationCallback() { - override fun onLocationResult(locationResult: LocationResult) { - locationResult.lastLocation?.let { currentLocation -> - Timber.d("Location received: ${currentLocation.latitude}, ${currentLocation.longitude}") - handleNewLocation(currentLocation) - } ?: Timber.w("Received null location in onLocationResult") - } - } + private fun handleDefaultAction() { + handleStartService() + } - // Request an immediate location update first - Timber.d("Requesting immediate location update...") - fusedLocationProviderClient.getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, null) - .addOnSuccessListener { location -> - location?.let { - Timber.d("Got immediate location: ${it.latitude}, ${it.longitude}") - handleNewLocation(it) - } ?: Timber.w("Immediate location request returned null") - } - .addOnFailureListener { e -> - Timber.e(e, "Failed to get immediate location") - } + private fun shouldAddToBuffer(location: Location, uploadTimeInterval: Long, uploadDistanceInterval: Long): Boolean { + val lastLoc = lastBufferLocation ?: return true - // Set up the regular location updates - fusedLocationProviderClient.requestLocationUpdates( - locationRequest, - locationCallback!!, - Looper.getMainLooper() - ).addOnFailureListener { e -> - Timber.e(e, "Failed to request location updates.") - stopSelf() - }.addOnSuccessListener { - Timber.d("Location updates requested successfully.") - } + val timeElapsed = System.currentTimeMillis() - lastLoc.time + val distance = location.distanceTo(lastLoc) - } catch (e: Exception) { - Timber.e(e, "Exception in startLocationUpdates: ${e.message}") - stopSelf() - } + return timeElapsed > TimeUnit.MINUTES.toMillis(uploadTimeInterval) + || distance > uploadDistanceInterval } - /** - * Processes a new location update. - * - * Submits the location processing task to the background executor to avoid - * blocking the main thread. The background task handles location state updates - * and uploading to the server with retry logic. - * - * @param currentLocation The new location from FusedLocationProviderClient - */ - private fun handleNewLocation(currentLocation: Location) { - Timber.d("handleNewLocation: Received location ${currentLocation.latitude}, ${currentLocation.longitude}") - - Timber.d("handleNewLocation: Submitting location to background executor.") - - // Submit the processing and upload task to the background executor - backgroundExecutor?.submit { - Timber.i("Executor task started for location: ${currentLocation.latitude}, ${currentLocation.longitude}") - try { - // 1. Gather necessary data - Timber.d("Executor: Fetching settings...") - // Wrap suspend calls with runBlocking - val username = runBlocking { settingsRepository.getCurrentUsername() } - val sessionId = runBlocking { settingsRepository.getCurrentSessionId() } - val appId = runBlocking { settingsRepository.getAppId() } - Timber.i("Executor: Got username=$username, sessionId=$sessionId, appId=$appId") - - // 2. Save location state (updates latestLocation and totalDistance in repo) - Timber.i("Executor: Saving location state via repository...") - try { - runBlocking { locationRepository.saveAsPreviousLocation(currentLocation) } - Timber.d("Executor: Location state saved.") - } catch (e: Exception) { - Timber.e(e, "Executor: Failed to save location state") - // Exit the executor task if state saving fails - return@submit + private suspend fun uploadBuffer(trackerId: String) { + uploadMutex.withLock { + Timber.tag(TAG).i("Attempting to upload buffer: ${locationBuffer.size} points") + if (locationBuffer.isEmpty()) { + return + } + + while (true) { + val next: Location = bufferMutex.withLock { + val loc = locationBuffer.firstOrNull() ?: return + locationBuffer.removeAt(0) + _bufferCount.value = locationBuffer.size + loc } - // 3. Perform location upload with retry logic - Timber.i("Executor: Starting upload attempt loop...") - var success = false - var retryCount = 0 - val maxRetries = 3 - - while (!success && retryCount < maxRetries) { - try { - Timber.d("Executor: Upload Attempt ${retryCount + 1}/$maxRetries") - // Wrap suspend call with runBlocking - Timber.d("Executor: Entering runBlocking for uploadLocationData") - try { - success = runBlocking { - Timber.i("Executor: Inside runBlocking for uploadLocationData") - locationRepository.uploadLocationData( - location = currentLocation, - username = username, - sessionId = sessionId, - appId = appId, - eventType = "service-update-executor" - ) - } - Timber.d("Executor: Exited runBlocking for uploadLocationData successfully") - } catch (rbError: Exception) { - Timber.e( - rbError, - "Executor: Exception occurred WITHIN or AROUND runBlocking for uploadLocationData" - ) - success = false - } + val success = uploadRepository.uploadData(trackerId, next) - if (success) { - Timber.i("Executor: Upload SUCCESS! (Attempt ${retryCount + 1}) Lat=${currentLocation.latitude}, Lon=${currentLocation.longitude}") - } else { - Timber.w("Executor: Upload FAILED (Attempt ${retryCount + 1}). Will retry if possible.") - if (retryCount < maxRetries - 1) { - val delayMs = 1000L * (retryCount + 1) - Timber.d("Executor: Waiting ${delayMs}ms before retry...") - try { - Thread.sleep(delayMs) - } catch (ie: InterruptedException) { - Timber.w("Executor: Sleep interrupted during retry delay.") - Thread.currentThread().interrupt() - break // Exit retry loop if interrupted - } - } - } - } catch (uploadException: Exception) { - Timber.e( - uploadException, - "Executor: Exception during upload attempt ${retryCount + 1}" - ) - if (retryCount < maxRetries - 1) { - val delayMs = 1000L * (retryCount + 1) - Timber.d("Executor: Waiting ${delayMs}ms before retry after exception...") - try { - Thread.sleep(delayMs) - } catch (ie: InterruptedException) { - Timber.w("Executor: Sleep interrupted during retry delay after exception.") - Thread.currentThread().interrupt() - break - } - } + if (!success) { + bufferMutex.withLock { + locationBuffer.add(0, next) + _bufferCount.value = locationBuffer.size } - retryCount++ - } - if (!success) { - Timber.e("Executor: All upload attempts failed after $maxRetries retries for location ${currentLocation.latitude}, ${currentLocation.longitude}") + Timber.tag(TAG).w("Upload failed, stopping buffer processing until next cycle") + return } - - } catch (t: Throwable) { - Timber.e(t, "Executor: Uncaught Throwable inside background task") - } finally { - Timber.i("Executor task finished for location: ${currentLocation.latitude}, ${currentLocation.longitude}") } - } ?: Timber.e("handleNewLocation: Background executor is null, cannot submit task.") + } + } + + //endregion onStartCommand + + //region onTaskRemoved + + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + scheduleServiceRestart() + } + + private fun scheduleServiceRestart() { + val alarmManager = getSystemService(ALARM_SERVICE) as AlarmManager + val restartPendingIntent = createRestartPendingIntent() + + val triggerAtMillis = SystemClock.elapsedRealtime() + RESTART_DELAY_MS + alarmManager.set( + AlarmManager.ELAPSED_REALTIME, + triggerAtMillis, + restartPendingIntent + ) + } + + private fun createRestartPendingIntent(): PendingIntent { + val restartServiceIntent = Intent(applicationContext, TrackingService::class.java).apply { + action = ACTION_START_SERVICE + } + return PendingIntent.getService( + applicationContext, + RESTART_REQUEST_CODE, + restartServiceIntent, + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + ) } - /** - * Stops location updates and releases resources. - * - * Removes the location callback, releases the wake lock, - * and cleans up associated resources. - */ - private fun stopLocationUpdates() { - Timber.d("stopLocationUpdates called.") + //endregion onTaskRemoved + + //region WAKE LOCK - // Release wake lock + private fun setupWakeLock() { + val powerManager = getSystemService(POWER_SERVICE) as PowerManager + wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply { + setReferenceCounted(false) + } + Timber.tag(TAG).d("Wake lock created") + } + + private fun acquireWakeLock() { + wakeLock?.apply { + if (!isHeld) { + acquire(TimeUnit.HOURS.toMillis(24)) + Timber.tag(TAG).d("Wake lock acquired") + } else { + Timber.tag(TAG).d("Wake lock already held") + } + } ?: Timber.tag(TAG).e("Wake lock is null, cannot acquire") + } + + private fun releaseWakeLock() { wakeLock?.let { if (it.isHeld) { it.release() - Timber.d("Wake lock released") } } - - locationCallback?.let { - Timber.d("Stopping location updates...") - try { - val removeTask = fusedLocationProviderClient.removeLocationUpdates(it) - removeTask.addOnCompleteListener { task -> - if (task.isSuccessful) { - Timber.d("Location updates stopped successfully.") - } else { - Timber.w(task.exception, "Failed to stop location updates.") - } - } - } catch (e: SecurityException) { - Timber.e(e, "SecurityException while stopping location updates.") - } finally { - locationCallback = null - Timber.d("Location callback cleared.") - } - } ?: Timber.d("stopLocationUpdates called but locationCallback was already null.") + wakeLock = null + Timber.tag(TAG).d("Wake lock released") } - /** - * Creates the notification channel for Android O and above. - * - * This is required for displaying the foreground service notification. - */ - private fun createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + //endregion WAKE LOCK + + //region NOTIFICATION + + private fun setupNotificationChannel() { + getSystemService(NotificationManager::class.java)?.apply { val channel = NotificationChannel( NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_NAME, NotificationManager.IMPORTANCE_LOW ) - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.createNotificationChannel(channel) - Timber.d("Notification channel created.") + createNotificationChannel(channel) + Timber.tag(TAG).d("Notification channel created") } } - /** - * Creates the notification for the foreground service. - * - * This notification is displayed while the service is running - * to inform the user about the active tracking. - * - * @return The notification to display - */ private fun createNotification(): Notification { return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) .setContentTitle(getString(R.string.app_name)) .setContentText(getString(R.string.notification_text)) .setSmallIcon(R.drawable.ic_notification_tracking) .setOngoing(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .build() } + + //endregion NOTIFICATION } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/TrackingViewModel.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/TrackingViewModel.kt index 038796f8..579eb200 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/TrackingViewModel.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/TrackingViewModel.kt @@ -1,4 +1,3 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/ui/TrackingViewModel.kt package com.websmithing.gpstracker2.ui import android.content.Context @@ -10,11 +9,10 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.data.repository.ForegroundLocationRepository -import com.websmithing.gpstracker2.data.repository.LocationRepository -import com.websmithing.gpstracker2.data.repository.SettingsRepository -import com.websmithing.gpstracker2.data.repository.UploadStatus +import com.websmithing.gpstracker2.repository.location.LocationRepository +import com.websmithing.gpstracker2.repository.settings.SettingsRepository +import com.websmithing.gpstracker2.repository.upload.UploadRepository +import com.websmithing.gpstracker2.repository.upload.UploadStatus import com.websmithing.gpstracker2.service.TrackingService import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext @@ -23,236 +21,107 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import timber.log.Timber -import java.util.UUID import javax.inject.Inject -/** - * ViewModel for the GPS tracking functionality. - * - * This ViewModel serves as the bridge between the UI and the data layer, handling user actions, - * managing application state, and coordinating between repositories and services. - * It's responsible for: - * - Starting and stopping the tracking service - * - Maintaining UI state (tracking status, settings) - * - Providing location data and tracking statistics to the UI - * - Managing user preferences - * - * The ViewModel uses Hilt for dependency injection and follows MVVM architecture. - */ @HiltViewModel class TrackingViewModel @Inject constructor( @param:ApplicationContext private val context: Context, private val settingsRepository: SettingsRepository, private val locationRepository: LocationRepository, - private val foregroundLocationRepository: ForegroundLocationRepository + private val uploadRepository: UploadRepository ) : ViewModel() { - // --- LiveData for UI State --- - /** - * Tracks whether location tracking is currently active - */ private val _isTracking = MutableLiveData() val isTracking: LiveData = _isTracking - /** - * Stores the current username for tracking identification - */ - private val _userName = MutableLiveData() - val userName: LiveData = _userName + private val _trackerIdentifier = MutableLiveData() + val trackerIdentifier: LiveData = _trackerIdentifier - /** - * Stores the current tracking interval in minutes (1, 5, or 15) - */ - private val _trackingInterval = MutableLiveData() - val trackingInterval: LiveData = _trackingInterval + private val _uploadServer = MutableLiveData() + val uploadServer: LiveData = _uploadServer - /** - * Stores the current website URL where tracking data is sent - */ - private val _websiteUrl = MutableLiveData() - val websiteUrl: LiveData = _websiteUrl + private val _uploadTimeInterval = MutableLiveData() + val uploadTimeInterval: LiveData = _uploadTimeInterval + + private val _uploadDistanceInterval = MutableLiveData() + val uploadDistanceInterval: LiveData = _uploadDistanceInterval private val _language = MutableLiveData() val language: LiveData = _language - /** - * Temporary messages to display to the user via Snackbar - */ private val _snackbarMessage = MutableLiveData() val snackbarMessage: LiveData = _snackbarMessage - // --- StateFlows for Location Data --- - /** - * The most recent location data received from the location services - * Exposes the repository's location flow as a StateFlow for the UI to collect - */ - val latestLocation: StateFlow = locationRepository.latestLocation + val latestLocation: StateFlow = locationRepository.currentLocation .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - /** - * The most recent location data in foreground mode and is not related to the logic of - * sending tracking data over the network - */ - val latestForegroundLocation: StateFlow = - foregroundLocationRepository.currentLocation - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - - /** - * The total distance traveled during the current tracking session in meters - * Exposes the repository's distance flow as a StateFlow for the UI to collect - */ - val totalDistance: StateFlow = locationRepository.totalDistance - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0f) - - /** - * The status of the most recent location data upload to the server - * Exposes the repository's upload status flow as a StateFlow for the UI to collect - */ - val lastUploadStatus: StateFlow = locationRepository.lastUploadStatus + val lastUploadStatus: StateFlow = uploadRepository.lastUploadStatus .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UploadStatus.Idle) - /** - * Job reference for the initialization coroutine (useful for testing) - */ - internal val initJob: Job + val bufferCount: StateFlow = TrackingService.bufferCount + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) - /** - * Initialize the ViewModel by loading current settings from repositories - */ init { - // Initialize state from Repository using viewModelScope - initJob = viewModelScope.launch { - _isTracking.value = settingsRepository.getCurrentTrackingState() - _userName.value = settingsRepository.getCurrentUsername() - _trackingInterval.value = settingsRepository.getCurrentTrackingInterval() - _websiteUrl.value = settingsRepository.getCurrentWebsiteUrl() - _language.value = settingsRepository.getCurrentLanguage() - Timber.d("ViewModel initialized. Tracking: ${isTracking.value}") - - // Check if first time loading and generate App ID if needed + viewModelScope.launch { + _isTracking.value = settingsRepository.getTrackingState() + _trackerIdentifier.value = settingsRepository.getTrackerIdentifier() + _uploadServer.value = settingsRepository.getUploadServer() + _uploadTimeInterval.value = settingsRepository.getUploadTimeInterval() + _uploadDistanceInterval.value = settingsRepository.getUploadDistanceInterval() + _language.value = settingsRepository.getLanguage() + if (settingsRepository.isFirstTimeLoading()) { - Timber.d("First time loading detected, generating App ID.") settingsRepository.generateAndSaveAppId() - settingsRepository.setFirstTimeLoading(false) // Mark as no longer first time + settingsRepository.setFirstTimeLoading(false) } - } - } - - override fun onCleared() { - super.onCleared() - stopForegroundLocation() - } - - // --- Actions from UI --- - /** - * Starts location tracking after permissions are granted - * - * This should only be called by the Activity after confirming all required - * permissions have been granted by the user. - */ - fun startTracking() { - Timber.d("startTracking called in ViewModel") - updateTrackingState(true) - } - - /** - * Stops location tracking - * - * Can be called by the Activity when the user requests to stop tracking - * or directly by the ViewModel in response to errors. - */ - fun stopTracking() { - Timber.d("stopTracking called in ViewModel") - updateTrackingState(false) - } - /** - * Forces tracking to stop in case of permission denial or errors - * - * Called by the Activity if the user denies required permissions during - * an attempt to start tracking. - */ - fun forceStopTracking() { - Timber.d("forceStopTracking called in ViewModel") - if (_isTracking.value == true) { - updateTrackingState(false) + restartForegroundServiceIfRequired() } } - fun switchTrackingState() { - if (isTracking.value == true) { - stopTracking() - } else { - startTracking() - } - } + //region SETTING CHANGES - fun startForegroundLocation() = foregroundLocationRepository.start() - - fun stopForegroundLocation() = foregroundLocationRepository.stop() - - /** - * Updates the tracking interval setting - * - * If tracking is currently active, this will restart the tracking service - * to apply the new interval immediately. - * - * @param newInterval The new tracking interval in minutes (1, 5, or 15) - */ - fun onIntervalChanged(newInterval: Int) { - if (newInterval != _trackingInterval.value) { - Timber.d("Interval changed to: $newInterval minutes") - _trackingInterval.value = newInterval + fun onTrackerIdentifierChanged(newValue: String) { + val newTrackerIdentifier = newValue.trim() + if (newTrackerIdentifier != _trackerIdentifier.value) { + _trackerIdentifier.value = newTrackerIdentifier viewModelScope.launch { - settingsRepository.saveTrackingInterval(newInterval) - // If currently tracking, stop and restart the service to apply the new interval - if (_isTracking.value == true) { - _snackbarMessage.value = context.getString(R.string.interval_updated) - // Stop the service - Intent(context, TrackingService::class.java).also { intent -> - intent.action = TrackingService.ACTION_STOP_SERVICE - context.stopService(intent) - } - // Start the service again (it will read the new interval) - Intent(context, TrackingService::class.java).also { intent -> - intent.action = TrackingService.ACTION_START_SERVICE - context.startForegroundService(intent) - } - } + settingsRepository.setTrackingIdentifier(newTrackerIdentifier) } } } - /** - * Updates the username setting - * - * @param newName The new username for tracking identification - */ - fun onUserNameChanged(newName: String) { - val trimmedName = newName.trim() - if (trimmedName != _userName.value) { - _userName.value = trimmedName + fun onUploadServerChanged(newValue: String) { + val newServerAddress = newValue.trim() + if (newServerAddress != _uploadServer.value) { + _uploadServer.value = newServerAddress viewModelScope.launch { - settingsRepository.saveUsername(trimmedName) - Timber.d("Username saved: $trimmedName") + settingsRepository.setUploadServer(newServerAddress) } } } - /** - * Updates the website URL setting - * - * @param newUrl The new URL where tracking data will be sent - */ - fun onWebsiteUrlChanged(newUrl: String) { - val trimmedUrl = newUrl.trim() - if (trimmedUrl != _websiteUrl.value && trimmedUrl.isNotEmpty()) { - _websiteUrl.value = trimmedUrl - viewModelScope.launch { - settingsRepository.saveWebsiteUrl(trimmedUrl) - Timber.d("Website URL saved: $trimmedUrl") - } + fun onTimeIntervalChanged(newValue: String) { + val newTimeInterval = newValue.toIntOrNull() ?: return + if (newTimeInterval == _uploadTimeInterval.value) return + + _uploadTimeInterval.value = newTimeInterval + viewModelScope.launch { + settingsRepository.setUploadTimeInterval(newTimeInterval) + + restartForegroundServiceIfRequired() + } + } + + fun onDistanceIntervalChanged(newValue: String) { + val newDistanceInterval = newValue.toIntOrNull() ?: return + if (newDistanceInterval == _uploadDistanceInterval.value) return + + _uploadDistanceInterval.value = newDistanceInterval + viewModelScope.launch { + settingsRepository.setUploadDistanceInterval(newDistanceInterval) + + restartForegroundServiceIfRequired() } } @@ -262,59 +131,69 @@ class TrackingViewModel @Inject constructor( LocaleListCompat.forLanguageTags(language) ) viewModelScope.launch { - settingsRepository.saveLanguage(language) + settingsRepository.setLanguage(language) _language.value = language } } } - /** - * Marks a snackbar message as shown to prevent reappearance - */ - fun onSnackbarMessageShown() { - _snackbarMessage.value = null + //endregion SETTING CHANGES + + //region MAP LOCATION UPDATE + + fun startForegroundLocation() = locationRepository.start() + + fun stopForegroundLocation() = locationRepository.stop() + + //endregion MAP LOCATION UPDATE + + //region UPLOADING CONTROL + + fun startTracking() { + updateTrackingState(true) + } + + fun stopTracking() { + updateTrackingState(false) + } + + fun switchTrackingState() { + if (isTracking.value == true) { + stopTracking() + } else { + startTracking() + } } - // --- Private Helper Methods --- - - /** - * Updates the tracking state and handles service lifecycle - * - * This method persists the tracking state to settings, manages the tracking session ID, - * and starts or stops the TrackingService as appropriate. - * - * @param shouldTrack Whether tracking should be active - * @return The coroutine Job handling the update operations - */ private fun updateTrackingState(shouldTrack: Boolean): Job { if (_isTracking.value == shouldTrack) return Job().apply { complete() } _isTracking.value = shouldTrack + return viewModelScope.launch { settingsRepository.setTrackingState(shouldTrack) + if (shouldTrack) { - val newSessionId = UUID.randomUUID().toString() - settingsRepository.saveSessionId(newSessionId) - locationRepository.resetLocationState() // Reset location repo state - // Start the foreground service + uploadRepository.resetUploadStatus() Intent(context, TrackingService::class.java).also { intent -> intent.action = TrackingService.ACTION_START_SERVICE context.startForegroundService(intent) - Timber.i("Tracking started via ViewModel. Session: $newSessionId. Service started.") } } else { - settingsRepository.clearSessionId() - // Stop the foreground service Intent(context, TrackingService::class.java).also { intent -> intent.action = TrackingService.ACTION_STOP_SERVICE - context.stopService(intent) // Use stopService for foreground services - Timber.i("Tracking stopped via ViewModel. Service stopped.") + context.stopService(intent) } } } } - companion object { - private const val TAG = "TrackingViewModel" + private fun restartForegroundServiceIfRequired() { + if (_isTracking.value != true) return + + stopTracking() + startTracking() } + + //endregion UPLOADING CONTROL } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/Utils.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/Utils.kt index 2d33a553..f185c64e 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/Utils.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/Utils.kt @@ -4,7 +4,6 @@ import android.Manifest import android.app.Activity import android.content.Context import android.content.pm.PackageManager -import android.location.Location import android.widget.Toast import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext @@ -15,26 +14,15 @@ import androidx.lifecycle.ViewModelStoreOwner import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.websmithing.gpstracker2.R -import org.maplibre.spatialk.geojson.Position import timber.log.Timber -import java.util.UUID -// Constants needed for SharedPreferences checkFirstTimeLoading -private const val PREFS_NAME = "com.websmithing.gpstracker.prefs" -private const val KEY_FIRST_TIME_LOADING = "firstTimeLoadingApp" -private const val KEY_APP_ID = "appID" - -fun Location.toPosition() = - Position(longitude = longitude, latitude = latitude, altitude = altitude) +@Composable +inline fun activityHiltViewModel(): VM { + val context = LocalContext.current + val viewModelStoreOwner = context as? ViewModelStoreOwner ?: error("Context is not a ViewModelStoreOwner") + return hiltViewModel(viewModelStoreOwner) +} -/** - * Checks if Google Play Services is available and enabled - * - * Shows an appropriate error dialog if Google Play Services is unavailable - * or needs to be updated. - * - * @return True if Google Play Services is available and up-to-date - */ fun Activity.checkIfGooglePlayEnabled(): Boolean { val googleApiAvailability = GoogleApiAvailability.getInstance() val resultCode = googleApiAvailability.isGooglePlayServicesAvailable(this) @@ -55,39 +43,6 @@ fun Activity.checkIfGooglePlayEnabled(): Boolean { } } -/** - * Performs first-time app setup - * - * Generates a unique app ID and stores it in SharedPreferences along with - * a flag indicating the app has been run at least once. - */ -fun Activity.checkFirstTimeLoading() { - val prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val firstTimeLoadingApp = prefs.getBoolean(KEY_FIRST_TIME_LOADING, true) - if (firstTimeLoadingApp) { - prefs.edit().apply { - putBoolean(KEY_FIRST_TIME_LOADING, false) - putString(KEY_APP_ID, UUID.randomUUID().toString()) - apply() - } - Timber.d("First time loading setup complete.") - } -} - - -@Composable -inline fun activityHiltViewModel(): VM { - val context = LocalContext.current - val viewModelStoreOwner = context as? ViewModelStoreOwner - ?: error("Context is not a ViewModelStoreOwner") - return hiltViewModel(viewModelStoreOwner) -} - -fun hasSpaces(str: String) = str.contains(' ') - fun isBackgroundLocationPermissionGranted(context: Context): Boolean { - return ContextCompat.checkSelfPermission( - context, - Manifest.permission.ACCESS_BACKGROUND_LOCATION - ) == PackageManager.PERMISSION_GRANTED + return ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_BACKGROUND_LOCATION) == PackageManager.PERMISSION_GRANTED } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/DragHandle.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomDragHandle.kt similarity index 54% rename from phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/DragHandle.kt rename to phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomDragHandle.kt index 55c61ba9..2674ac4e 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/DragHandle.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomDragHandle.kt @@ -4,48 +4,37 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.R import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.websmithing.gpstracker2.ui.theme.WaliotTheme @Composable -fun DragHandle( +fun CustomDragHandle( modifier: Modifier = Modifier, width: Dp = 56.dp, height: Dp = 4.dp, shape: Shape = MaterialTheme.shapes.extraLarge, - color: Color = Color(0x804B5A66), + color: Color = Color(0x804B5A66) ) { - val context = LocalContext.current - val dragHandleDescription = context.getString(R.string.m3c_bottom_sheet_drag_handle_description) Surface( - modifier = - modifier - .padding(vertical = 10.dp) - .semantics { - contentDescription = dragHandleDescription - }, + modifier = modifier.padding(vertical = 10.dp), color = color, - shape = shape, + shape = shape ) { - Box(Modifier.Companion.size(width = width, height = height)) + Box(Modifier.size(width = width, height = height)) } } @Preview @Composable -private fun TrackingInfoSheetPreview() { +private fun CustomDragHandlePreview() { WaliotTheme { - DragHandle() + CustomDragHandle() } } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomFloatingButton.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomFloatingButton.kt index e7773b1c..89df977f 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomFloatingButton.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomFloatingButton.kt @@ -29,8 +29,8 @@ fun CustomFloatingButton( onClick: () -> Unit, icon: @Composable () -> Unit ) { - val animatedContentColor: Color by animateColorAsState(targetValue = contentColor) val animatedColor: Color by animateColorAsState(targetValue = color) + val animatedContentColor: Color by animateColorAsState(targetValue = contentColor) Surface( color = animatedColor, @@ -38,7 +38,7 @@ fun CustomFloatingButton( shape = shape, shadowElevation = elevation, onClick = onClick, - modifier = modifier, + modifier = modifier ) { icon() } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/LabeledBox.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomLabeledBox.kt similarity index 69% rename from phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/LabeledBox.kt rename to phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomLabeledBox.kt index ef4b4283..359ee04b 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/LabeledBox.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomLabeledBox.kt @@ -6,11 +6,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.websmithing.gpstracker2.ui.theme.WaliotTheme @Composable -fun LabeledBox( - modifier: Modifier = Modifier.Companion, +fun CustomLabeledBox( + modifier: Modifier = Modifier, label: String, content: @Composable () -> Unit ) { @@ -27,3 +29,13 @@ fun LabeledBox( content() } } + +@Preview +@Composable +private fun CustomLabeledBoxPreview() { + WaliotTheme { + CustomLabeledBox(label = "Label") { + Text("Content") + } + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/PermissionDeniedDialog.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomPermissionDeniedDialog.kt similarity index 74% rename from phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/PermissionDeniedDialog.kt rename to phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomPermissionDeniedDialog.kt index 1465ded0..15ec4251 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/PermissionDeniedDialog.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomPermissionDeniedDialog.kt @@ -9,11 +9,13 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import com.websmithing.gpstracker2.R +import com.websmithing.gpstracker2.ui.theme.WaliotTheme @Composable -fun PermissionDeniedDialog( - modifier: Modifier = Modifier.Companion, +fun CustomPermissionDeniedDialog( + modifier: Modifier = Modifier, onDismissRequest: () -> Unit, text: String ) { @@ -39,3 +41,14 @@ fun PermissionDeniedDialog( modifier = modifier, ) } + +@Preview +@Composable +private fun CustomPermissionDeniedDialogPreview() { + WaliotTheme { + CustomPermissionDeniedDialog( + onDismissRequest = {}, + text = "This is a dialog" + ) + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomSnackbar.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomSnackbar.kt index 75255565..60df0746 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomSnackbar.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/components/CustomSnackbar.kt @@ -12,13 +12,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.websmithing.gpstracker2.R +import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.extendedColors enum class CustomSnackbarType { - success, - warning + SUCCESS, + WARNING } @Composable @@ -30,12 +32,12 @@ fun CustomSnackbar( ) { Snackbar( containerColor = when (type) { - CustomSnackbarType.success -> MaterialTheme.extendedColors.okContainer - CustomSnackbarType.warning -> MaterialTheme.extendedColors.warningContainer + CustomSnackbarType.SUCCESS -> MaterialTheme.extendedColors.okContainer + CustomSnackbarType.WARNING -> MaterialTheme.extendedColors.warningContainer }, contentColor = MaterialTheme.colorScheme.onPrimary, shape = RectangleShape, - modifier = modifier.clickable(true, onClick = onClick), + modifier = modifier.clickable(true, onClick = onClick) ) { Row( horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -43,8 +45,8 @@ fun CustomSnackbar( Icon( painterResource( when (type) { - CustomSnackbarType.success -> R.drawable.ic_ok_16 - CustomSnackbarType.warning -> R.drawable.ic_warning_16 + CustomSnackbarType.SUCCESS -> R.drawable.ic_ok_16 + CustomSnackbarType.WARNING -> R.drawable.ic_warning_16 } ), tint = Color.Unspecified, @@ -54,3 +56,27 @@ fun CustomSnackbar( } } } + +@Preview +@Composable +private fun SuccessCustomSnackbarPreview() { + WaliotTheme { + CustomSnackbar( + type = CustomSnackbarType.SUCCESS, + message = "This is a success!", + onClick = {} + ) + } +} + +@Preview +@Composable +private fun WarningCustomSnackbarPreview() { + WaliotTheme { + CustomSnackbar( + type = CustomSnackbarType.WARNING, + message = "This is a warning!", + onClick = {} + ) + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/HomePage.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/HomePage.kt index fd475621..07819995 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/HomePage.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/HomePage.kt @@ -1,6 +1,7 @@ package com.websmithing.gpstracker2.ui.features.home import android.annotation.SuppressLint +import android.location.Location import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize @@ -15,7 +16,6 @@ import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -36,13 +36,14 @@ import androidx.compose.ui.unit.isSpecified import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.data.repository.UploadStatus +import com.websmithing.gpstracker2.repository.upload.UploadStatus import com.websmithing.gpstracker2.ui.TrackingViewModel import com.websmithing.gpstracker2.ui.activityHiltViewModel import com.websmithing.gpstracker2.ui.components.CustomFloatingButton +import com.websmithing.gpstracker2.ui.components.CustomPermissionDeniedDialog import com.websmithing.gpstracker2.ui.components.CustomSnackbar import com.websmithing.gpstracker2.ui.components.CustomSnackbarType -import com.websmithing.gpstracker2.ui.components.PermissionDeniedDialog +import com.websmithing.gpstracker2.ui.features.home.components.DEFAULT_MAP_ZOOM import com.websmithing.gpstracker2.ui.features.home.components.LocationMarker import com.websmithing.gpstracker2.ui.features.home.components.LocationMarkerSize import com.websmithing.gpstracker2.ui.features.home.components.LocationMarkerState @@ -53,13 +54,13 @@ import com.websmithing.gpstracker2.ui.features.home.components.TrackingButtonSta import com.websmithing.gpstracker2.ui.features.home.components.TrackingInfoSheet import com.websmithing.gpstracker2.ui.isBackgroundLocationPermissionGranted import com.websmithing.gpstracker2.ui.router.AppDestination -import com.websmithing.gpstracker2.ui.toPosition import kotlinx.coroutines.launch import org.maplibre.compose.camera.CameraPosition import org.maplibre.compose.camera.rememberCameraState +import org.maplibre.spatialk.geojson.Position import kotlin.time.Duration.Companion.milliseconds -private const val defaultZoom = 15.0 +private fun Location.toPosition() = Position(longitude = longitude, latitude = latitude, altitude = altitude) @Composable fun HomePage( @@ -69,10 +70,10 @@ fun HomePage( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val snackbarHostState = remember { SnackbarHostState() } + var showBackgroundDeniedDialog by remember { mutableStateOf(false) } val cameraState = rememberCameraState() - val latestLocation by viewModel.latestForegroundLocation.collectAsStateWithLifecycle() + val latestLocation by viewModel.latestLocation.collectAsStateWithLifecycle() val markerPosition by remember(cameraState.position, latestLocation) { derivedStateOf { latestLocation?.let { location -> @@ -81,27 +82,34 @@ fun HomePage( } } + val snackbarHostState = remember { SnackbarHostState() } + val snackbarMessage by viewModel.snackbarMessage.observeAsState() + val isTracking by viewModel.isTracking.observeAsState(false) - val userName by viewModel.userName.observeAsState() - val websiteUrl by viewModel.websiteUrl.observeAsState() - val canRunTracking by remember(userName, websiteUrl) { + val lastUploadStatus by viewModel.lastUploadStatus.collectAsStateWithLifecycle() + val bufferCount by viewModel.bufferCount.collectAsStateWithLifecycle() + var showTrackingInfoSheet by remember { mutableStateOf(false) } + + val trackerIdentifier by viewModel.trackerIdentifier.observeAsState() + val uploadServer by viewModel.uploadServer.observeAsState() + val canRunTracking by remember(trackerIdentifier, uploadServer) { derivedStateOf { - !websiteUrl.isNullOrEmpty() && !userName.isNullOrEmpty() + !uploadServer.isNullOrBlank() && !trackerIdentifier.isNullOrBlank() } } - val lastUploadStatus by viewModel.lastUploadStatus.collectAsStateWithLifecycle() - val snackbarMessage by viewModel.snackbarMessage.observeAsState() - var showTrackingInfoSheet by remember { mutableStateOf(false) } - var showBackgroundDeniedDialog by remember { mutableStateOf(false) } + LocationPermissionFlow( + onAllow = { viewModel.startForegroundLocation() }, + onDeny = { viewModel.stopForegroundLocation() } + ) LaunchedEffect(latestLocation) { - val oldZoom = cameraState.position.zoom + val curZoom = cameraState.position.zoom latestLocation?.let { cameraState.animateTo( CameraPosition( target = it.toPosition(), - zoom = if (oldZoom == 1.0) defaultZoom else oldZoom, + zoom = if (curZoom == 1.0) DEFAULT_MAP_ZOOM else curZoom, ), duration = 500.milliseconds, ) @@ -113,7 +121,19 @@ fun HomePage( scope.launch { snackbarHostState.showSnackbar( it, - actionLabel = CustomSnackbarType.success.name, + actionLabel = CustomSnackbarType.SUCCESS.name, + duration = SnackbarDuration.Short + ) + } + } + } + + LaunchedEffect(isTracking) { + if (isTracking) { + scope.launch { + snackbarHostState.showSnackbar( + context.getString(R.string.tracking_enabled), + actionLabel = CustomSnackbarType.SUCCESS.name, duration = SnackbarDuration.Short ) } @@ -128,7 +148,7 @@ fun HomePage( R.string.error_format, status.errorMessage ?: context.getString(R.string.unknown_error) ), - actionLabel = CustomSnackbarType.warning.name, + actionLabel = CustomSnackbarType.WARNING.name, duration = SnackbarDuration.Short ) } @@ -137,44 +157,23 @@ fun HomePage( } } - LaunchedEffect(isTracking) { - if (isTracking) { - scope.launch { - snackbarHostState.showSnackbar( - context.getString(R.string.tracking_enabled), - actionLabel = CustomSnackbarType.success.name, - duration = SnackbarDuration.Short - ) - } - } - } - - DisposableEffect(true) { - onDispose { - viewModel.stopForegroundLocation() - } - } - - LocationPermissionFlow( - onAllow = { viewModel.startForegroundLocation() }, - onDeny = { viewModel.stopForegroundLocation() } - ) - fun switchTracking() { if (!canRunTracking) { return } + if (!isBackgroundLocationPermissionGranted(context)) { showBackgroundDeniedDialog = true return } + try { viewModel.switchTrackingState() } catch (e: Exception) { scope.launch { snackbarHostState.showSnackbar( - context.getString(R.string.error_format, e.message ?: e.toString()), - actionLabel = CustomSnackbarType.warning.name, + context.getString(R.string.error_format, e.localizedMessage ?: R.string.unknown_error), + actionLabel = CustomSnackbarType.WARNING.name, duration = SnackbarDuration.Short ) } @@ -185,11 +184,11 @@ fun HomePage( floatingActionButton = { TrackingButton( state = if (!canRunTracking) { - TrackingButtonState.Stop + TrackingButtonState.STOP } else if (isTracking) { - TrackingButtonState.Pause + TrackingButtonState.PAUSE } else { - TrackingButtonState.Play + TrackingButtonState.PLAY } ) { switchTracking() @@ -224,10 +223,11 @@ fun HomePage( LocationMarker( onClick = { showTrackingInfoSheet = true }, state = when { - !showTrackingInfoSheet -> LocationMarkerState.Inactive - userName.isNullOrEmpty() -> LocationMarkerState.Error - else -> LocationMarkerState.Active + !showTrackingInfoSheet -> LocationMarkerState.INACTIVE + trackerIdentifier.isNullOrEmpty() -> LocationMarkerState.ERROR + else -> LocationMarkerState.ACTIVE }, + rotation = (latestLocation?.bearing ?: 0f) - 45f, modifier = Modifier.offset( x = markerPosition.x - LocationMarkerSize / 2, y = markerPosition.y - LocationMarkerSize / 2 @@ -240,15 +240,15 @@ fun HomePage( if (showTrackingInfoSheet) { TrackingInfoSheet( onDismissRequest = { showTrackingInfoSheet = false }, - userName = userName, + trackerIdentifier = trackerIdentifier, location = latestLocation, - totalDistance = viewModel.totalDistance, - lastUploadStatus = lastUploadStatus + lastUploadStatus = lastUploadStatus, + bufferCount = bufferCount ) } if (showBackgroundDeniedDialog) { - PermissionDeniedDialog( + CustomPermissionDeniedDialog( text = context.getString(R.string.permission_denied_background_location), onDismissRequest = { showBackgroundDeniedDialog = false } ) diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationMarker.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationMarker.kt index b95d9871..9838fd31 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationMarker.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationMarker.kt @@ -8,6 +8,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -17,9 +18,9 @@ import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.extendedColors enum class LocationMarkerState { - Active, - Inactive, - Error + ACTIVE, + INACTIVE, + ERROR } val LocationMarkerSize = 48.dp @@ -27,7 +28,8 @@ val LocationMarkerSize = 48.dp @Composable fun LocationMarker( modifier: Modifier = Modifier, - state: LocationMarkerState = LocationMarkerState.Inactive, + state: LocationMarkerState = LocationMarkerState.INACTIVE, + rotation: Float = 0f, onClick: () -> Unit, ) { val teardropShape = RoundedCornerShape( @@ -39,22 +41,30 @@ fun LocationMarker( CustomFloatingButton( color = when (state) { - LocationMarkerState.Active -> MaterialTheme.colorScheme.primary - LocationMarkerState.Inactive -> MaterialTheme.extendedColors.fab - LocationMarkerState.Error -> MaterialTheme.colorScheme.error + LocationMarkerState.ACTIVE -> MaterialTheme.colorScheme.primary + LocationMarkerState.INACTIVE -> MaterialTheme.extendedColors.fab + LocationMarkerState.ERROR -> MaterialTheme.colorScheme.error }, contentColor = when (state) { - LocationMarkerState.Active, LocationMarkerState.Error -> Color.White - LocationMarkerState.Inactive -> MaterialTheme.extendedColors.onFab + LocationMarkerState.ACTIVE, LocationMarkerState.ERROR -> Color.White + LocationMarkerState.INACTIVE -> MaterialTheme.extendedColors.onFab }, shape = teardropShape, onClick = onClick, - modifier = modifier.size(LocationMarkerSize) + modifier = modifier + .size(LocationMarkerSize) + .graphicsLayer { + rotationZ = rotation + } ) { Icon( painterResource(R.drawable.ic_person_32), - contentDescription = "Текущая локация", - modifier = Modifier.requiredSize(32.dp) + contentDescription = null, + modifier = Modifier + .requiredSize(32.dp) + .graphicsLayer { + rotationZ = -rotation + } ) } } @@ -71,7 +81,7 @@ private fun LocationMarkerPreview() { @Composable private fun LocationMarkerActivePreview() { WaliotTheme { - LocationMarker(onClick = {}, state = LocationMarkerState.Active) + LocationMarker(onClick = {}, state = LocationMarkerState.ACTIVE) } } @@ -79,6 +89,6 @@ private fun LocationMarkerActivePreview() { @Composable private fun LocationMarkerErrorPreview() { WaliotTheme { - LocationMarker(onClick = {}, state = LocationMarkerState.Error) + LocationMarker(onClick = {}, state = LocationMarkerState.ERROR) } } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationPermissionFlow.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationPermissionFlow.kt index e88b1e87..4802e922 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationPermissionFlow.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/LocationPermissionFlow.kt @@ -16,7 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.app.ActivityCompat import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.ui.components.PermissionDeniedDialog +import com.websmithing.gpstracker2.ui.components.CustomPermissionDeniedDialog import com.websmithing.gpstracker2.ui.isBackgroundLocationPermissionGranted private val permissions = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -82,7 +82,6 @@ fun ForegroundLocation( } else { onShowBackgroundDialog() } - } else { onAllow() } @@ -131,7 +130,7 @@ fun ForegroundLocation( } if (showForegroundDeniedDialog) { - PermissionDeniedDialog( + CustomPermissionDeniedDialog( text = context.getString(R.string.permission_denied_foreground_location), onDismissRequest = { showForegroundDeniedDialog = false } ) @@ -195,7 +194,7 @@ private fun BackgroundLocation( } if (showBackgroundDeniedDialog) { - PermissionDeniedDialog( + CustomPermissionDeniedDialog( text = context.getString(R.string.permission_denied_background_location), onDismissRequest = { showBackgroundDeniedDialog = false } ) diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/MapView.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/MapView.kt index 544e0067..fd44c423 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/MapView.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/MapView.kt @@ -8,14 +8,15 @@ import org.maplibre.compose.map.MaplibreMap import org.maplibre.compose.map.OrnamentOptions import org.maplibre.compose.style.BaseStyle -private const val mapStyleUri = "https://tiles.openfreemap.org/styles/liberty" +const val MAP_STYLE_URL = "https://tiles.openfreemap.org/styles/liberty" +const val DEFAULT_MAP_ZOOM = 15.0 @Composable fun MapView(modifier: Modifier = Modifier, cameraState: CameraState) { MaplibreMap( - baseStyle = BaseStyle.Uri(mapStyleUri), + baseStyle = BaseStyle.Uri(MAP_STYLE_URL), cameraState = cameraState, options = MapOptions(ornamentOptions = OrnamentOptions.AllDisabled), - modifier = modifier, + modifier = modifier ) -} \ No newline at end of file +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingButton.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingButton.kt index cd21023c..a7eedf5e 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingButton.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingButton.kt @@ -18,9 +18,9 @@ import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.extendedColors enum class TrackingButtonState { - Stop, - Play, - Pause, + STOP, + PLAY, + PAUSE } val TrackingButtonSize = 56.dp @@ -28,18 +28,18 @@ val TrackingButtonSize = 56.dp @Composable fun TrackingButton( modifier: Modifier = Modifier, - state: TrackingButtonState = TrackingButtonState.Stop, + state: TrackingButtonState = TrackingButtonState.STOP, onClick: () -> Unit, ) { CustomFloatingButton( color = when (state) { - TrackingButtonState.Play -> MaterialTheme.extendedColors.ok - TrackingButtonState.Stop -> MaterialTheme.extendedColors.fab - TrackingButtonState.Pause -> MaterialTheme.colorScheme.error + TrackingButtonState.PLAY -> MaterialTheme.extendedColors.ok + TrackingButtonState.STOP -> MaterialTheme.extendedColors.fab + TrackingButtonState.PAUSE -> MaterialTheme.colorScheme.error }, contentColor = when (state) { - TrackingButtonState.Play, TrackingButtonState.Pause -> Color.White - TrackingButtonState.Stop -> MaterialTheme.extendedColors.onFab + TrackingButtonState.PLAY, TrackingButtonState.PAUSE -> Color.White + TrackingButtonState.STOP -> MaterialTheme.extendedColors.onFab }, elevation = 12.dp, onClick = debounced( @@ -50,19 +50,19 @@ fun TrackingButton( modifier = modifier.size(TrackingButtonSize) ) { when (state) { - TrackingButtonState.Play -> Icon( + TrackingButtonState.PLAY -> Icon( painterResource(R.drawable.ic_play_24), contentDescription = stringResource(R.string.tracking_is_off), modifier = Modifier.requiredSize(24.dp) ) - TrackingButtonState.Stop -> Icon( + TrackingButtonState.STOP -> Icon( painterResource(R.drawable.ic_stop_24), contentDescription = stringResource(R.string.tracking_is_off), modifier = Modifier.requiredSize(24.dp) ) - TrackingButtonState.Pause -> Icon( + TrackingButtonState.PAUSE -> Icon( painterResource(R.drawable.ic_pause_24), contentDescription = stringResource(R.string.tracking_is_on), modifier = Modifier.requiredSize(24.dp) @@ -83,7 +83,7 @@ private fun TrackingButtonPreview() { @Composable private fun TrackingButtonStartPreview() { WaliotTheme { - TrackingButton(onClick = {}, state = TrackingButtonState.Play) + TrackingButton(onClick = {}, state = TrackingButtonState.PLAY) } } @@ -91,6 +91,6 @@ private fun TrackingButtonStartPreview() { @Composable private fun TrackingButtonStopPreview() { WaliotTheme { - TrackingButton(onClick = {}, state = TrackingButtonState.Pause) + TrackingButton(onClick = {}, state = TrackingButtonState.PAUSE) } } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingInfoSheet.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingInfoSheet.kt index 96e0a1ba..1939843b 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingInfoSheet.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/home/components/TrackingInfoSheet.kt @@ -21,8 +21,6 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,37 +30,33 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.data.repository.UploadStatus -import com.websmithing.gpstracker2.ui.components.DragHandle +import com.websmithing.gpstracker2.repository.upload.UploadStatus +import com.websmithing.gpstracker2.ui.components.CustomDragHandle import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.extendedColors -import kotlinx.coroutines.flow.StateFlow import java.text.DecimalFormat import java.text.SimpleDateFormat import java.util.Locale -private val coordinateFormatter = DecimalFormat("0.00000") -private val distanceFormatter = DecimalFormat("0.0") +private val coordinateFormatter = DecimalFormat("0.000000") +private val timeFormatter = SimpleDateFormat("HH:mm:ss, dd.MM.yy", Locale.US) @Composable fun TrackingInfoSheet( - userName: String?, + trackerIdentifier: String?, location: Location?, - totalDistance: StateFlow, lastUploadStatus: UploadStatus?, + bufferCount: Int, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { - val totalDistanceValue by totalDistance.collectAsStateWithLifecycle() - Sheet( onDismissRequest = onDismissRequest, - userName = userName, + trackerIdentifier = trackerIdentifier, location = location, - totalDistance = totalDistanceValue, lastUploadStatus = lastUploadStatus, + bufferCount = bufferCount, modifier = modifier ) } @@ -70,26 +64,25 @@ fun TrackingInfoSheet( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun Sheet( - userName: String?, + trackerIdentifier: String?, location: Location?, - totalDistance: Float, lastUploadStatus: UploadStatus?, + bufferCount: Int, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current - val timeFormatter = remember { SimpleDateFormat("HH:mm:ss, dd.MM.yy", Locale.getDefault()) } ModalBottomSheet( onDismissRequest = onDismissRequest, containerColor = MaterialTheme.extendedColors.appBar, contentColor = MaterialTheme.colorScheme.onSurface, shape = RectangleShape, - dragHandle = { DragHandle() }, + dragHandle = { CustomDragHandle() }, scrimColor = Color.Transparent, modifier = modifier, ) { - UserNameBadge(value = userName) + TrackerIdentifierBadge(value = trackerIdentifier) ProvideTextStyle(MaterialTheme.typography.labelMedium) { Grid( @@ -104,11 +97,8 @@ private fun Sheet( item(span = { GridItemSpan(2) }) { Text( - if (location == null) - context.getString(R.string.search_satellites) - else "${ - coordinateFormatter.format(location.latitude) - }, ${coordinateFormatter.format(location.longitude)}" + if (location == null) context.getString(R.string.search_satellites) + else "${coordinateFormatter.format(location.latitude)}; ${coordinateFormatter.format(location.longitude)}" ) } @@ -170,8 +160,7 @@ private fun Sheet( is UploadStatus.Failure -> context.getString( R.string.upload_status_failure, timeFormatter.format(location.time), - lastUploadStatus.errorMessage - ?: stringResource(R.string.unknown_error) + lastUploadStatus.errorMessage ?: stringResource(R.string.unknown_error) ) } } @@ -179,7 +168,7 @@ private fun Sheet( } } - ExtraInfo(context, location, totalDistance) + ExtraInfo(context, location, bufferCount) } } } @@ -188,7 +177,7 @@ private fun Sheet( private fun ExtraInfo( context: Context, location: Location?, - totalDistance: Float + bufferCount: Int ) { Column( verticalArrangement = Arrangement.spacedBy(5.dp), @@ -213,37 +202,29 @@ private fun ExtraInfo( context.getString(R.string.accuracy_format, location.accuracy) ) - ChipItem( - label = context.getString(R.string.distance), - value = if (totalDistance == 0f) - context.getString(R.string.no_data_placeholder) - else - context.getString( - R.string.distance_format_km, - distanceFormatter.format(totalDistance / 1000f) - ) - ) - - // TODO ChipItem( label = stringResource(R.string.data_buffer), - value = context.getString(R.string.no_data_placeholder) + value = if (bufferCount > 0) { + bufferCount.toString() + } else { + context.getString(R.string.no_data_placeholder) + } ) } } } @Composable -fun UserNameBadge(modifier: Modifier = Modifier, value: String?) { - val noUserName = value == null || value.isEmpty() +fun TrackerIdentifierBadge(modifier: Modifier = Modifier, value: String?) { + val noValue = value.isNullOrBlank() Text( - if (noUserName) stringResource(R.string.provide_identifier) else value, + if (noValue) stringResource(R.string.provide_identifier) else value, style = MaterialTheme.typography.labelLarge, modifier = modifier .padding(vertical = 12.dp, horizontal = 16.dp) .clip(shape = MaterialTheme.shapes.extraSmall) .background( - color = if (noUserName) + color = if (noValue) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary @@ -294,18 +275,12 @@ private fun ChipItem( } } -/** - * Gets a human-readable description of signal strength based on accuracy - * - * @param accuracy The accuracy of the location in meters - * @return A string describing the signal strength - */ @Composable private fun getSignalStrengthDescription(accuracy: Float): String { val context = LocalContext.current return when { - accuracy <= 0 -> context.getString(R.string.signal_unknown) // Accuracy shouldn't be <= 0 - accuracy <= 10 -> context.getString(R.string.signal_excellent) // meters + accuracy <= 0 -> context.getString(R.string.signal_unknown) + accuracy <= 10 -> context.getString(R.string.signal_excellent) accuracy <= 25 -> context.getString(R.string.signal_good) accuracy <= 50 -> context.getString(R.string.signal_fair) else -> context.getString(R.string.signal_poor) @@ -314,28 +289,28 @@ private fun getSignalStrengthDescription(accuracy: Float): String { @Preview @Composable -private fun TrackingInfoSheetPreview() { +private fun SheetPreview() { WaliotTheme { Sheet( onDismissRequest = {}, - userName = null, - location = null, - totalDistance = 0f, - lastUploadStatus = null + trackerIdentifier = "89181201004", + location = Location(""), + lastUploadStatus = UploadStatus.Success, + bufferCount = 7 ) } } @Preview @Composable -private fun TrackingInfoSheetEmpty() { +private fun EmptySheetPreview() { WaliotTheme { Sheet( onDismissRequest = {}, - userName = "89181201004", - location = Location(""), - totalDistance = 7000f, - lastUploadStatus = null + trackerIdentifier = null, + location = null, + lastUploadStatus = null, + bufferCount = 0 ) } } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/SettingsPage.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/SettingsPage.kt index 8b184de3..70fb3d5f 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/SettingsPage.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/SettingsPage.kt @@ -33,12 +33,16 @@ import androidx.compose.ui.unit.dp import androidx.core.text.isDigitsOnly import androidx.navigation.NavHostController import com.websmithing.gpstracker2.R +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_LANGUAGE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_TRACKER_IDENTIFIER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_DISTANCE_INTERVAL +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_SERVER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_TIME_INTERVAL import com.websmithing.gpstracker2.ui.TrackingViewModel import com.websmithing.gpstracker2.ui.activityHiltViewModel import com.websmithing.gpstracker2.ui.components.CustomBackButton import com.websmithing.gpstracker2.ui.features.settings.components.SettingsForm import com.websmithing.gpstracker2.ui.features.settings.model.SettingsFormState -import com.websmithing.gpstracker2.ui.hasSpaces import com.websmithing.gpstracker2.ui.modifiers.unfocus import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.customButtonColors @@ -56,17 +60,18 @@ fun SettingsPage( val context = LocalContext.current val focusManager = LocalFocusManager.current - val userName by viewModel.userName.observeAsState() - val websiteUrl by viewModel.websiteUrl.observeAsState() + val trackerIdentifier by viewModel.trackerIdentifier.observeAsState() + val uploadServer by viewModel.uploadServer.observeAsState() + val uploadTimeInterval by viewModel.uploadTimeInterval.observeAsState() + val uploadDistanceInterval by viewModel.uploadDistanceInterval.observeAsState() val language by viewModel.language.observeAsState() - val intervalTime by viewModel.trackingInterval.observeAsState() var initialState = SettingsFormState( - userName = userName ?: "", - websiteUrl = websiteUrl ?: context.getString(R.string.default_upload_website), - intervalTime = intervalTime?.toString() ?: "1", - intervalDistance = "100", - languageCode = language ?: "ru" + trackerIdentifier = trackerIdentifier ?: DEFAULT_TRACKER_IDENTIFIER, + uploadServer = uploadServer ?: DEFAULT_UPLOAD_SERVER, + uploadTimeInterval = uploadTimeInterval?.toString() ?: DEFAULT_UPLOAD_TIME_INTERVAL.toString(), + uploadDistanceInterval = uploadDistanceInterval?.toString() ?: DEFAULT_UPLOAD_DISTANCE_INTERVAL.toString(), + languageCode = language ?: DEFAULT_LANGUAGE ) var state by remember { mutableStateOf(initialState) } val canSave by remember(state, initialState) { @@ -76,76 +81,69 @@ fun SettingsPage( } fun saveAndValidate(): Boolean { - val name = state.userName.trim() - val website = state.websiteUrl.trim() - val intervalTime = state.intervalTime.trim().let { if (it.isEmpty()) 0 else it.toInt() } - val intervalDistance = - state.intervalDistance.trim().let { if (it.isEmpty()) 0 else it.toInt() } - - val isNameValid = name.isEmpty() || name.isDigitsOnly() - val isWebsiteValid = website.isNotBlank() && !hasSpaces(website) - val isIntervalTimeValid = intervalTime > 0 - val isIntervalDistanceValid = intervalDistance > 0 - - state = if (!isNameValid) { - state.copy(userNameError = context.getString(R.string.username_error_spaces)) + val identifier = state.trackerIdentifier.trim() + val serverAddress = state.uploadServer.trim() + val timeInterval = state.uploadTimeInterval.trim().let { if (it.isEmpty()) 0 else it.toInt() } + val distanceInterval = state.uploadDistanceInterval.trim().let { if (it.isEmpty()) 0 else it.toInt() } + + val isIdentifierValid = identifier.isEmpty() || identifier.isDigitsOnly() + val isServerAddressValid = serverAddress.isNotBlank() && !serverAddress.contains(' ') + val isTimeIntervalValid = timeInterval > 0 + val isDistanceIntervalValid = distanceInterval > 0 + + state = if (!isIdentifierValid) { + state.copy(trackerIdentifierError = context.getString(R.string.tracker_identifier_error)) } else { - state.copy(userNameError = null) + state.copy(trackerIdentifierError = null) } - if (!isWebsiteValid) { - if (website.isBlank()) { - state = - state.copy(websiteUrlError = context.getString(R.string.website_error_empty)) - } else if (hasSpaces(website)) { - state = - state.copy(websiteUrlError = context.getString(R.string.website_error_spaces)) - } + state = if (!isServerAddressValid) { + state.copy(uploadServerError = context.getString(R.string.upload_server_error)) } else { - state = state.copy(websiteUrlError = null) + state.copy(uploadServerError = null) } - state = if (!isIntervalTimeValid) { - state.copy(intervalTimeError = context.getString(R.string.interval_error)) + state = if (!isTimeIntervalValid) { + state.copy(uploadTimeIntervalError = context.getString(R.string.interval_error)) } else { - state.copy(intervalTimeError = null) + state.copy(uploadTimeIntervalError = null) } - state = if (!isIntervalDistanceValid) { - state.copy(intervalDistanceError = context.getString(R.string.interval_error)) + state = if (!isDistanceIntervalValid) { + state.copy(uploadDistanceIntervalError = context.getString(R.string.interval_error)) } else { - state.copy(intervalDistanceError = null) + state.copy(uploadDistanceIntervalError = null) } focusManager.clearFocus(true) - if (isNameValid && isWebsiteValid && isIntervalDistanceValid && isIntervalTimeValid) { - val userNameChanged = initialState.userName != state.userName + if (isIdentifierValid && isServerAddressValid && isDistanceIntervalValid && isTimeIntervalValid) { + val trackerIdentifierChanged = initialState.trackerIdentifier != state.trackerIdentifier + val uploadServerChanged = initialState.uploadServer != state.uploadServer + val timeIntervalChanged = initialState.uploadTimeInterval != state.uploadTimeInterval + val distanceIntervalChanged = initialState.uploadDistanceInterval != state.uploadDistanceInterval val languageChanged = initialState.languageCode != state.languageCode - val websiteUrlChanged = initialState.websiteUrl != state.websiteUrl - val intervalTimeChanged = initialState.intervalTime != state.intervalTime - val intervalDistanceChanged = initialState.intervalDistance != state.intervalDistance - if (userNameChanged) { - viewModel.onUserNameChanged(state.userName.trim()) + if (trackerIdentifierChanged) { + viewModel.onTrackerIdentifierChanged(state.trackerIdentifier.trim()) } - if (websiteUrlChanged) { - viewModel.onWebsiteUrlChanged(state.websiteUrl.trim()) + if (uploadServerChanged) { + viewModel.onUploadServerChanged(state.uploadServer.trim()) } - if (intervalTimeChanged) { - viewModel.onIntervalChanged(intervalTime) + if (timeIntervalChanged) { + viewModel.onTimeIntervalChanged(state.uploadTimeInterval.trim()) } - if (intervalDistanceChanged) { - // TODO + if (distanceIntervalChanged) { + viewModel.onDistanceIntervalChanged(state.uploadDistanceInterval.trim()) } if (languageChanged) { - viewModel.onLanguageChanged(state.languageCode) + viewModel.onLanguageChanged(state.languageCode.trim()) } initialState = state return true } else { - Toast.makeText(context, R.string.textfields_empty_or_spaces, Toast.LENGTH_LONG).show() + Toast.makeText(context, R.string.text_fields_empty_or_spaces, Toast.LENGTH_LONG).show() return false } } @@ -231,14 +229,9 @@ private fun SaveButton( @Preview @Composable private fun PagePreview() { - val context = LocalContext.current - WaliotTheme { Page( - state = SettingsFormState( - websiteUrl = context.getString(R.string.default_upload_website), - languageCode = "ru" - ), + state = SettingsFormState(), canSave = true, onChange = {}, onBack = {}, diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/components/SettingsForm.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/components/SettingsForm.kt index cdaba006..f296ac83 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/components/SettingsForm.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/components/SettingsForm.kt @@ -24,7 +24,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.SpanStyle @@ -36,7 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.websmithing.gpstracker2.BuildConfig import com.websmithing.gpstracker2.R -import com.websmithing.gpstracker2.ui.components.LabeledBox +import com.websmithing.gpstracker2.ui.components.CustomLabeledBox import com.websmithing.gpstracker2.ui.features.settings.model.SettingsFormState import com.websmithing.gpstracker2.ui.theme.WaliotTheme import com.websmithing.gpstracker2.ui.theme.customOutlinedTextFieldColors @@ -58,14 +57,14 @@ fun SettingsForm( .verticalScroll(rememberScrollState()) .padding(vertical = 13.dp, horizontal = 16.dp) ) { - LabeledBox(label = stringResource(R.string.user_name)) { + CustomLabeledBox(label = stringResource(R.string.tracker_identifier)) { OutlinedTextField( - value = state.userName, + value = state.trackerIdentifier, onValueChange = { value -> - onChange(state.copy(userName = value.filter { it.isDigit() })) + onChange(state.copy(trackerIdentifier = value.filter { it.isDigit() })) }, - isError = state.userNameError != null, - supportingText = { state.userNameError?.let { Text(it) } }, + isError = state.trackerIdentifierError != null, + supportingText = { state.trackerIdentifierError?.let { Text(it) } }, colors = customOutlinedTextFieldColors(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number @@ -75,26 +74,26 @@ fun SettingsForm( ) } - LabeledBox(label = stringResource(R.string.upload_website)) { + CustomLabeledBox(label = stringResource(R.string.upload_server)) { OutlinedTextField( - value = state.websiteUrl, - onValueChange = { onChange(state.copy(websiteUrl = it)) }, - isError = state.websiteUrlError != null, - supportingText = { state.websiteUrlError?.let { Text(it) } }, + value = state.uploadServer, + onValueChange = { onChange(state.copy(uploadServer = it)) }, + isError = state.uploadServerError != null, + supportingText = { state.uploadServerError?.let { Text(it) } }, colors = customOutlinedTextFieldColors(), modifier = Modifier .fillMaxWidth() ) } - LabeledBox(label = stringResource(R.string.upload_frequency)) { + CustomLabeledBox(label = stringResource(R.string.upload_time_interval)) { OutlinedTextField( - value = state.intervalTime, + value = state.uploadTimeInterval, onValueChange = { value -> - onChange(state.copy(intervalTime = value.filter { it.isDigit() })) + onChange(state.copy(uploadTimeInterval = value.filter { it.isDigit() })) }, - isError = state.intervalTimeError != null, - supportingText = { state.intervalTimeError?.let { Text(it) } }, + isError = state.uploadTimeIntervalError != null, + supportingText = { state.uploadTimeIntervalError?.let { Text(it) } }, colors = customOutlinedTextFieldColors(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number @@ -104,14 +103,14 @@ fun SettingsForm( ) } - LabeledBox(label = stringResource(R.string.upload_distance)) { + CustomLabeledBox(label = stringResource(R.string.upload_distance_interval)) { OutlinedTextField( - value = state.intervalDistance, + value = state.uploadDistanceInterval, onValueChange = { value -> - onChange(state.copy(intervalTime = value.filter { it.isDigit() })) + onChange(state.copy(uploadDistanceInterval = value.filter { it.isDigit() })) }, - isError = state.intervalDistanceError != null, - supportingText = { state.intervalDistanceError?.let { Text(it) } }, + isError = state.uploadDistanceIntervalError != null, + supportingText = { state.uploadDistanceIntervalError?.let { Text(it) } }, colors = customOutlinedTextFieldColors(), keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Number @@ -121,7 +120,7 @@ fun SettingsForm( ) } - LabeledBox(label = stringResource(R.string.language)) { + CustomLabeledBox(label = stringResource(R.string.language)) { SelectLanguage( selected = state.languageCode, onSelect = { onChange(state.copy(languageCode = it)) } @@ -202,14 +201,9 @@ private fun Logo(modifier: Modifier = Modifier) { @Preview @Composable private fun SettingsFormPreview() { - val context = LocalContext.current - WaliotTheme { SettingsForm( - state = SettingsFormState( - websiteUrl = context.getString(R.string.default_upload_website), - languageCode = "ru" - ), + state = SettingsFormState(), onChange = {}, ) } diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/model/SettingsFormState.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/model/SettingsFormState.kt index e09c45cf..2be4aa7e 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/model/SettingsFormState.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/settings/model/SettingsFormState.kt @@ -1,13 +1,20 @@ package com.websmithing.gpstracker2.ui.features.settings.model +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_LANGUAGE +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_TRACKER_IDENTIFIER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_DISTANCE_INTERVAL +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_SERVER +import com.websmithing.gpstracker2.repository.settings.SettingsRepository.Companion.DEFAULT_UPLOAD_TIME_INTERVAL + data class SettingsFormState( - val userName: String = "", - val websiteUrl: String = "", - val intervalTime: String = "1", - val intervalDistance: String = "100", - val languageCode: String = "", - val userNameError: String? = null, - val websiteUrlError: String? = null, - val intervalTimeError: String? = null, - val intervalDistanceError: String? = null, -) \ No newline at end of file + val trackerIdentifier: String = DEFAULT_TRACKER_IDENTIFIER, + val uploadServer: String = DEFAULT_UPLOAD_SERVER, + val uploadTimeInterval: String = DEFAULT_UPLOAD_TIME_INTERVAL.toString(), + val uploadDistanceInterval: String = DEFAULT_UPLOAD_DISTANCE_INTERVAL.toString(), + val languageCode: String = DEFAULT_LANGUAGE, + + val trackerIdentifierError: String? = null, + val uploadServerError: String? = null, + val uploadTimeIntervalError: String? = null, + val uploadDistanceIntervalError: String? = null, +) diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/splash/SplashPage.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/splash/SplashPage.kt index b2ef0370..e1509e05 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/splash/SplashPage.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/features/splash/SplashPage.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.delay import kotlin.time.Duration.Companion.seconds @Composable -fun SplashPage(navController: NavHostController, modifier: Modifier = Modifier) { +fun SplashPage(navController: NavHostController) { LaunchedEffect(true) { delay(3.seconds) navController.navigate(AppDestination.Home) { diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/modifiers/DebounceModifier.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/modifiers/DebounceModifier.kt index 3f319d93..ecb6b2e5 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/modifiers/DebounceModifier.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/modifiers/DebounceModifier.kt @@ -1,24 +1,15 @@ package com.websmithing.gpstracker2.ui.modifiers import android.os.SystemClock -import androidx.compose.foundation.clickable import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed -/** - * Wraps an [onClick] lambda with another one that supports debouncing. The default deboucing time - * is 1000ms. - * - * @return debounced onClick - */ @Composable inline fun debounced(crossinline onClick: () -> Unit, debounceTime: Long = 1000L): () -> Unit { - var lastTimeClicked by remember { mutableStateOf(0L) } + var lastTimeClicked by remember { mutableLongStateOf(0L) } val onClickLambda: () -> Unit = { val now = SystemClock.uptimeMillis() if (now - lastTimeClicked > debounceTime) { @@ -28,16 +19,3 @@ inline fun debounced(crossinline onClick: () -> Unit, debounceTime: Long = 1000L } return onClickLambda } - -/** - * The same as [Modifier.clickable] with support to debouncing. - */ -fun Modifier.debouncedClickable( - debounceTime: Long = 1000L, - onClick: () -> Unit -): Modifier { - return this.composed { - val clickable = debounced(debounceTime = debounceTime, onClick = { onClick() }) - this.clickable { clickable() } - } -} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/router/NavDestination.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/router/NavDestination.kt index 073fe1df..ae0921ea 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/router/NavDestination.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/router/NavDestination.kt @@ -6,6 +6,7 @@ interface Destination @Serializable sealed class AppDestination : Destination { + @Serializable data object Splash : AppDestination() @@ -14,4 +15,4 @@ sealed class AppDestination : Destination { @Serializable data object Settings : AppDestination() -} \ No newline at end of file +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/theme/Type.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/theme/Type.kt index 1e58d320..061083f9 100644 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/theme/Type.kt +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/ui/theme/Type.kt @@ -16,7 +16,6 @@ val LabGrotesqueFontFamily = FontFamily( ) ) -// Set of Material typography styles to start with private val defaultTypography = Typography() val Typography = Typography( displayMedium = defaultTypography.displayMedium.copy( diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/CrcUtils.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/CrcUtils.kt new file mode 100644 index 00000000..8ac07aa6 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/CrcUtils.kt @@ -0,0 +1,52 @@ +package com.websmithing.gpstracker2.util + +object CrcUtils { + + private val CRC16_TABLE = intArrayOf( + 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, + 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, + 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, + 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, + 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, + 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, + 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, + 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, + 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, + 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, + 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, + 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, + 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, + 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, + 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, + 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, + 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, + 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, + 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, + 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, + 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, + 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, + 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, + 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, + 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, + 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, + 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, + 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, + 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, + 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, + 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, + 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040 + ) + + fun calculateCrc16(data: ByteArray): Int { + var crc = 0xFFFF + for (b in data) { + val index = (crc xor (b.toInt() and 0xFF)) and 0xFF + crc = (crc shr 8) xor CRC16_TABLE[index] + } + return crc + } + + fun formatCrcToHex(crc: Int): String { + return "%04X".format(crc) + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/LocaleHelper.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/LocaleHelper.kt deleted file mode 100644 index 6f94c4c3..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/LocaleHelper.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.websmithing.gpstracker2.util - -import android.content.Context -import android.content.res.Configuration -import android.content.res.Resources -import com.websmithing.gpstracker2.data.repository.SettingsRepository -import java.util.Locale - -object LocaleHelper { - - /** - * Attaches the saved locale to the base context of an Activity or Service. - * This must be called in `attachBaseContext`. - */ - fun onAttach(context: Context, settingsRepository: SettingsRepository): Context { - val currentLanguage = settingsRepository.getCurrentLanguage() - return setAppLocale(context, currentLanguage) - } - - private fun setAppLocale(context: Context, languageCode: String): Context { - val locale = Locale.forLanguageTag(languageCode) - Locale.setDefault(locale) - - val resources: Resources = context.resources - val config: Configuration = resources.configuration - config.setLocale(locale) - - return context.createConfigurationContext(config) - } -} \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/NmeaUtils.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/NmeaUtils.kt new file mode 100644 index 00000000..ded39c30 --- /dev/null +++ b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/NmeaUtils.kt @@ -0,0 +1,69 @@ +package com.websmithing.gpstracker2.util + +import java.util.Locale +import kotlin.math.abs +import kotlin.math.floor +import kotlin.math.roundToInt + +object NmeaUtils { + + private const val MIN_LATITUDE_VALUE: Double = -90.0 + private const val MAX_LATITUDE_VALUE: Double = 90.0 + private const val MIN_LONGITUDE_VALUE: Double = -180.0 + private const val MAX_LONGITUDE_VALUE: Double = 180.0 + + private const val NORTH: Char = 'N' + private const val SOUTH: Char = 'S' + private const val WEST: Char = 'W' + private const val EAST: Char = 'E' + + private const val NMEA_MINUTES_DIVIDER: Double = 1e4 + private const val NMEA_MINUTES_MAX_VALUE: Double = 60.0 + + fun latitudeToDdm(latitude: Double, separator: String): String { + require(latitude in MIN_LATITUDE_VALUE..MAX_LATITUDE_VALUE) { + "Latitude value $latitude is out of range" + } + require(separator.isNotEmpty()) { "Separator cannot be null or empty" } + + val (degrees, wholeMinutes, fracMinutes) = buildDdm(latitude) + return String.format( + Locale.US, "%02d%02d.%04d%s%c", + degrees, wholeMinutes, fracMinutes, separator, if (latitude >= 0) NORTH else SOUTH + ) + } + + fun longitudeToDdm(longitude: Double, separator: String): String { + require(longitude in MIN_LONGITUDE_VALUE..MAX_LONGITUDE_VALUE) { + "Longitude value $longitude is out of range" + } + require(separator.isNotEmpty()) { "Separator cannot be null or empty" } + + val (degrees, wholeMinutes, fracMinutes) = buildDdm(longitude) + return String.format( + Locale.US, "%03d%02d.%04d%s%c", + degrees, wholeMinutes, fracMinutes, separator, if (longitude >= 0) EAST else WEST + ) + } + + private fun buildDdm(value: Double): IntArray { + val absValue = abs(value) + + val degrees = floor(absValue).toInt() + var minutes = (absValue - degrees) * NMEA_MINUTES_MAX_VALUE + + minutes = (minutes * NMEA_MINUTES_DIVIDER).roundToInt() / NMEA_MINUTES_DIVIDER + if (minutes >= NMEA_MINUTES_MAX_VALUE) { + minutes = 0.0 + } + + var wholeMinutes = minutes.toInt() + var fracMinutes = ((minutes - wholeMinutes) * NMEA_MINUTES_DIVIDER).roundToInt() + if (fracMinutes == NMEA_MINUTES_DIVIDER.toInt()) { + fracMinutes = 0 + wholeMinutes += 1 + } + + return intArrayOf(degrees, wholeMinutes, fracMinutes) + } +} diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionChecker.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionChecker.kt deleted file mode 100644 index ea58dd22..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionChecker.kt +++ /dev/null @@ -1,22 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionChecker.kt -package com.websmithing.gpstracker2.util - -/** - * Interface for checking app permissions. - * - * This interface abstracts permission checking functionality from its implementation, - * making it easier to test components that depend on permission checks by allowing - * the permission checker to be mocked. - */ -interface PermissionChecker { - /** - * Checks if either ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION permission is granted. - * - * The app requires at least one of these permissions to access the device's location. - * ACCESS_FINE_LOCATION provides precise location, while ACCESS_COARSE_LOCATION provides - * approximate location. - * - * @return true if either location permission is granted, false otherwise. - */ - fun hasLocationPermission(): Boolean -} \ No newline at end of file diff --git a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionCheckerImpl.kt b/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionCheckerImpl.kt deleted file mode 100644 index 9699fecd..00000000 --- a/phoneClients/android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionCheckerImpl.kt +++ /dev/null @@ -1,41 +0,0 @@ -// # android/app/src/main/java/com/websmithing/gpstracker2/util/PermissionCheckerImpl.kt -package com.websmithing.gpstracker2.util - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import androidx.core.content.ContextCompat -import dagger.hilt.android.qualifiers.ApplicationContext -import javax.inject.Inject -import javax.inject.Singleton - -/** - * Default implementation of [PermissionChecker] using Android's [ContextCompat]. - * - * This class uses Android's permission checking mechanisms to determine if the app - * has been granted the required location permissions. - * - * It is provided as a singleton through Hilt dependency injection to ensure - * consistent permission checking throughout the application. - */ -@Singleton -class PermissionCheckerImpl @Inject constructor( - @param:ApplicationContext private val appContext: Context -) : PermissionChecker { - - /** - * Checks if either ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION permission is granted. - * - * Uses [ContextCompat.checkSelfPermission] to safely check permission status on - * all Android versions. - * - * @return true if either location permission is granted, false otherwise. - */ - override fun hasLocationPermission(): Boolean { - return ContextCompat.checkSelfPermission( - appContext, Manifest.permission.ACCESS_FINE_LOCATION - ) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( - appContext, Manifest.permission.ACCESS_COARSE_LOCATION - ) == PackageManager.PERMISSION_GRANTED - } -} diff --git a/phoneClients/android/app/src/main/res/values-ru/strings.xml b/phoneClients/android/app/src/main/res/values-ru/strings.xml index 7ed01cc0..23431de2 100644 --- a/phoneClients/android/app/src/main/res/values-ru/strings.xml +++ b/phoneClients/android/app/src/main/res/values-ru/strings.xml @@ -1,23 +1,12 @@ - WALIOT.Tracker - device.waliot.com:30032 - - Начать отслеживание - Остановить отслеживание - Проверьте выделенные поля. Поля не могут быть пустыми или содержать пробелы. - Перейдите в Настройки > Приложения и включите Сервисы Google Play. - - - Номер трекера может состоять только из цифр. - Номер трекера не может быть пустым. - Адрес сервера не может быть пустым. - Адрес сервера не может содержать пробелы. - Интервал должен быть больше 0 + WALIOT.Трекер + трекер + Перейдите в Настройки > Приложения и включите Сервисы Google Play Требуется разрешение на определение местоположения - Приложению требуется доступ к местоположению для отслеживания. + Приложению требуется доступ к местоположению для отслеживания Требуется разрешение на фоновое определение местоположения Это приложение собирает данные о местоположении для отслеживания даже при закрытом приложении. Для этого требуется разрешение «Разрешить всегда». Пожалуйста, предоставьте его на следующем экране. Разрешение на определение местоположения в активном режиме отклонено. Отслеживание не может быть запущено. @@ -28,57 +17,66 @@ Продолжить Отмена - - Идентификатор - Адрес сервера и порт - Отправлять данные каждые, минут - Отправлять данные каждые, метров - 1 - 5 - 15 + + Настройки + Закрыть + Сохранить + Идентификатор + Адрес сервера + Отправлять данные каждые, минут + Отправлять данные каждые, метров + Язык + Английский + Русский + Версия: + + Проверьте выделенные поля. Поля не могут быть пустыми или содержать пробелы + Номер трекера не может быть пустым и должен состоять только из цифр + Адрес сервера не может быть пустым или содержать пробелы + Интервал должен быть больше 0 + Интервал обновлён. Перезапуск службы отслеживания. + + + + Укажите идентификатор + Ошибка: %1$s + Неизвестная ошибка + + Начать отслеживание + Остановить отслеживание + Идёт отслеживание местоположения… + Отслеживание включено - - Скорость - %.1f км/ч Координаты + + Высота %.0f м - %.0f м + + Скорость + %.1f км/ч + + Направление %.0f° - %s км - Сигнал GPS - - Обновление - Дополнительные данные + Точность - Расстояние - Высота - Направление + %.0f м - Поиск спутников… + Расстояние + %s км - %1$s (успешно) - %1$s (ошибка: %2$s) + Буфер + Сигнал GPS + Поиск спутников… Неизвестно Отлично Хорошо Удовлетворительно Плохо - Идёт отслеживание местоположения… + Обновление + Дополнительные данные - Язык - Английский - Русский - трекер - Настройки - Закрыть - Версия приложения: - Сохранить - Отслеживание включено - Интервал обновлён. Перезапуск службы отслеживания. - Ошибка: %1$s - Неизвестная ошибка - Укажите идентификатор - Буфер данных + %1$s (успешно) + %1$s (ошибка: %2$s) diff --git a/phoneClients/android/app/src/main/res/values/dimens.xml b/phoneClients/android/app/src/main/res/values/dimens.xml index 5c1e3846..3ea04e70 100644 --- a/phoneClients/android/app/src/main/res/values/dimens.xml +++ b/phoneClients/android/app/src/main/res/values/dimens.xml @@ -1,3 +1,2 @@ - - - + + diff --git a/phoneClients/android/app/src/main/res/values/strings.xml b/phoneClients/android/app/src/main/res/values/strings.xml index 951e52ba..da441dd8 100644 --- a/phoneClients/android/app/src/main/res/values/strings.xml +++ b/phoneClients/android/app/src/main/res/values/strings.xml @@ -1,24 +1,13 @@ WALIOT.Tracker - device.waliot.com:30032 - - Start Tracking - Stop Tracking - Please check the highlighted fields. Inputs cannot be empty or contain spaces. - Please go into Settings > Apps and enable Google Play Services. - - - The tracker number can only contain digits. - Tracker number cannot be empty. - Server address cannot be empty. - Server address cannot contain spaces. - The interval must be greater than 0 + tracker - Location Permission Needed - This app needs access to your location to track your position. - Background Location Permission Needed + Please go into Settings > Apps and enable Google Play Services + Location permission is needed + This app needs access to your location to track your position + Background location permission is needed This app collects location data to enable tracking even when the app is closed or not in use. This requires the "Allow all the time" location permission. Please grant this on the next screen. Foreground location permission denied. Tracking cannot start. Background location permission denied. Tracking may stop when the app is closed. @@ -28,58 +17,66 @@ Continue Cancel - - - Identifier - Server address and port - Send data every minutes - Send data every meters - 1 - 5 - 15 + + Settings + Close + Save + Identifier + Server address + Send data every minutes + Send data every meters + Language + English + Russian + Version: + + Please check the highlighted fields. Inputs cannot be empty or contain spaces + The tracker number cannot be empty and must contain digits only + Server address cannot be empty or contain spaces + The interval must be greater than 0 + Interval updated. Restarting tracking service. + + + + Please provide an identifier + Error: %1$s + Unknown error + + Start tracking + Stop tracking + Actively tracking location… + Tracking enabled - - Speed - %.1f km/h Coordinates + + Altitude %.0f m - %.0f m + + Speed + %.1f km/h + + Bearing %.0f° - %s km - GPS Signal - - Update - Extra data + Accuracy - Distance - Altitude - Bearing + %.0f m - Search satellites… + Distance + %s km - %1$s (Success) - %1$s (Failed: %2$s) + Buffer + GPS Signal + Search satellites… Unknown Excellent Good Fair Poor - Actively tracking location... + Update + Extra data - Language - English - Russian - tracker - Settings - Close - App version: - Save - Tracking enabled - Interval updated. Restarting tracking service. - Error: %1$s - Unknown error - Please provide an identifier - Data buffer + %1$s (Success) + %1$s (Failed: %2$s) diff --git a/phoneClients/android/app/src/main/res/values/themes.xml b/phoneClients/android/app/src/main/res/values/themes.xml index fcde1052..dcf8609d 100644 --- a/phoneClients/android/app/src/main/res/values/themes.xml +++ b/phoneClients/android/app/src/main/res/values/themes.xml @@ -1,12 +1,10 @@ - - \ No newline at end of file + diff --git a/phoneClients/android/app/src/test/java/com/websmithing/gpstracker2/GpsTrackerApplicationTest.kt b/phoneClients/android/app/src/test/java/com/websmithing/gpstracker2/GpsTrackerApplicationTest.kt index dcc00c7a..4d1318e8 100644 --- a/phoneClients/android/app/src/test/java/com/websmithing/gpstracker2/GpsTrackerApplicationTest.kt +++ b/phoneClients/android/app/src/test/java/com/websmithing/gpstracker2/GpsTrackerApplicationTest.kt @@ -7,6 +7,6 @@ class GpsTrackerApplicationTest { @Test fun test() { - assertEquals(4, 2 + 2) + assertEquals(4, 2 + 2) // too brave to write tests } } diff --git a/phoneClients/android/gradle.properties b/phoneClients/android/gradle.properties index 2737d205..c1065cdf 100644 --- a/phoneClients/android/gradle.properties +++ b/phoneClients/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8 android.useAndroidX=true android.enableJetifier=true diff --git a/phoneClients/android/gradle/wrapper/gradle-wrapper.properties b/phoneClients/android/gradle/wrapper/gradle-wrapper.properties index ed4c299a..7705927e 100644 --- a/phoneClients/android/gradle/wrapper/gradle-wrapper.properties +++ b/phoneClients/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME