diff --git a/.gitignore b/.gitignore
index 1109a75..890d16c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,12 +1,32 @@
-*.iml
+# Gradle
.gradle/
-/local.properties
-/.idea/
+build/
+*/build/
+
+# Android Studio
+.idea/
+*.iml
+
+# Local config
+local.properties
+
+# Logs
+*.log
+
+# OS
.DS_Store
-/build/
-/captures/
+
+# APK outputs
+*.apk
+
+# Keystore (VERY IMPORTANT)
+*.jks
+*.keystore
+
+# Secrets
+secrets.properties
+.env
+
+# NDK
.externalNativeBuild/
.cxx/
-app/build/
-
-local.properties
diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md
index be3a009..6f5a1ca 100644
--- a/PROJECT_STATE.md
+++ b/PROJECT_STATE.md
@@ -1,50 +1,16 @@
-# PROJECT_STATE.md
-
-## Project
-- Name: follow-me
-- App label: otb-tcom
-- Version: v0.1.0
-- Build date: 2026-03-16
-- Platform: Android
-- Package: top.outsidethebox.followme
-- Minimum Android version: 6.0 (API 23)
-
-## Current scope
-Working Android client scaffold for GPS tracking into OTB Tracker.
-
-## Current implemented features
-- Foreground tracking service
-- HTTPS primary ingest endpoint
-- HTTP fallback ingest endpoint
-- Device-local default-to-HTTP after 3 consecutive HTTPS fallback events
-- X-API-Key token header
-- Minimal installer/settings screen
-- Save/start/stop controls
-- Reboot restart when tracking was already enabled
-- Location payload fields aligned to provided ingest contract
-
-## Not yet implemented
-- Backend onboarding flow for freeadmin/drone install paths
-- Token issuance / enrollment API
-- Local queueing for offline retry backlog
-- Battery optimization exemption helper UI
-- Geofence pill creation in app
-- Admin / drone role-aware UI behavior
-- Play Store packaging / signing / release pipeline
-- Linux and Windows tracker clients
-
-## Endpoint contract
-Primary:
-- https://otb-tracker.outsidethebox.top/api/gps-ingest
-
-Fallback:
-- http://otb-tracker.outsidethebox.top/api/gps-ingest
-
-Header:
-- X-API-Key: token
-
-Method:
-- POST
-
-## Source label
-- Default source string: otb-tcom
+# follow-me-android Project State
+
+## Current State
+- Existing working build confirmed
+- Used for live GPS tracking
+- Integrated with otb-tracker backend
+
+## Next Steps
+- Align with follow-me-v2 API
+- Add token-based authentication
+- Improve retry + offline queue
+- Add configurable tracking modes
+
+## Notes
+- Must remain lightweight for older devices
+- Stability > features
diff --git a/README.md b/README.md
index 18423bc..c92be53 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,31 @@
-# follow-me
+# follow-me-android
-android app for https://otb-tracker.outsidethebox.top tracking
\ No newline at end of file
+Android client for the OTB Follow-me GPS tracking platform.
+
+## Version
+v0.5.x (existing working build baseline)
+
+## Purpose
+- Sends GPS data to Follow-me backend
+- Supports low-power and legacy Android devices
+- Works with follow-me-v2 backend
+
+## Features (planned / current)
+- GPS tracking with configurable interval
+- Background operation
+- Retry + offline buffering
+- Device ID + network binding
+- API key / token authentication (future)
+
+## Backend
+- OTB Tracker / follow-me-v2 API
+- Endpoint: /api/gps-ingest
+
+## Notes
+- Designed to work on older Android devices (Android 6+)
+- No Google Play dependency required
+- APK distribution via services portal
+
+## Security
+- No secrets committed
+- Device tokens handled per install
diff --git a/app.bak.20260330-214820/build.gradle.kts b/app.bak.20260330-214820/build.gradle.kts
new file mode 100644
index 0000000..6cc823e
--- /dev/null
+++ b/app.bak.20260330-214820/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "top.outsidethebox.followme"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "top.outsidethebox.followme"
+ minSdk = 23
+ targetSdk = 34
+ versionCode = 4
+ versionName = "0.4.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
+
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.11.0")
+ implementation("androidx.activity:activity-ktx:1.8.2")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.lifecycle:lifecycle-service:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("com.google.android.gms:play-services-location:21.2.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+}
diff --git a/app.bak.20260330-214820/proguard-rules.pro b/app.bak.20260330-214820/proguard-rules.pro
new file mode 100644
index 0000000..a1e5cc2
--- /dev/null
+++ b/app.bak.20260330-214820/proguard-rules.pro
@@ -0,0 +1 @@
+# No custom rules yet.
diff --git a/app.bak.20260330-214820/src/main/AndroidManifest.xml b/app.bak.20260330-214820/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..eaba08c
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
new file mode 100644
index 0000000..1b9dc72
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
@@ -0,0 +1,52 @@
+package top.outsidethebox.followme.data
+
+import android.content.Context
+import android.content.SharedPreferences
+
+class AppPrefs(context: Context) {
+ private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+
+ fun getToken(): String = prefs.getString(KEY_TOKEN, "") ?: ""
+ fun setToken(value: String) = prefs.edit().putString(KEY_TOKEN, value).apply()
+
+ fun getRole(): String = prefs.getString(KEY_ROLE, "drone") ?: "drone"
+ fun setRole(value: String) = prefs.edit().putString(KEY_ROLE, value).apply()
+
+ fun getNetworkNote(): String = prefs.getString(KEY_NETWORK_NOTE, "") ?: ""
+ fun setNetworkNote(value: String) = prefs.edit().putString(KEY_NETWORK_NOTE, value).apply()
+
+ fun getSource(): String = prefs.getString(KEY_SOURCE, DEFAULT_SOURCE) ?: DEFAULT_SOURCE
+ fun setSource(value: String) = prefs.edit().putString(KEY_SOURCE, value).apply()
+
+ fun isTrackingEnabled(): Boolean = prefs.getBoolean(KEY_TRACKING_ENABLED, false)
+ fun setTrackingEnabled(value: Boolean) = prefs.edit().putBoolean(KEY_TRACKING_ENABLED, value).apply()
+
+ fun getConsecutiveHttpsFallbacks(): Int = prefs.getInt(KEY_HTTPS_FALLBACKS, 0)
+ fun setConsecutiveHttpsFallbacks(value: Int) = prefs.edit().putInt(KEY_HTTPS_FALLBACKS, value).apply()
+
+ fun getPreferredTransport(): String = prefs.getString(KEY_PREFERRED_TRANSPORT, TRANSPORT_HTTPS) ?: TRANSPORT_HTTPS
+ fun setPreferredTransport(value: String) = prefs.edit().putString(KEY_PREFERRED_TRANSPORT, value).apply()
+
+ fun getLastUpload(): String = prefs.getString(KEY_LAST_UPLOAD, "never") ?: "never"
+ fun setLastUpload(value: String) = prefs.edit().putString(KEY_LAST_UPLOAD, value).apply()
+
+ fun resetHttpsFallbackCounter() = setConsecutiveHttpsFallbacks(0)
+
+ companion object {
+ private const val PREF_NAME = "follow_me_prefs"
+ private const val KEY_TOKEN = "token"
+ private const val KEY_ROLE = "role"
+ private const val KEY_NETWORK_NOTE = "network_note"
+ private const val KEY_SOURCE = "source"
+ private const val KEY_TRACKING_ENABLED = "tracking_enabled"
+ private const val KEY_HTTPS_FALLBACKS = "https_fallbacks"
+ private const val KEY_PREFERRED_TRANSPORT = "preferred_transport"
+ private const val KEY_LAST_UPLOAD = "last_upload"
+
+ const val TRANSPORT_HTTPS = "https"
+ const val TRANSPORT_HTTP = "http"
+ const val DEFAULT_SOURCE = "otb-tcom"
+ const val HTTPS_ENDPOINT = "https://otb-tracker.outsidethebox.top/api/gps-ingest"
+ const val HTTP_ENDPOINT = "http://otb-tracker.outsidethebox.top/api/gps-ingest"
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
new file mode 100644
index 0000000..b91dad8
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
@@ -0,0 +1,27 @@
+package top.outsidethebox.followme.data
+
+import org.json.JSONObject
+
+data class GpsPayload(
+ val latitude: Double,
+ val longitude: Double,
+ val speedKmh: Double? = null,
+ val accuracyM: Float? = null,
+ val headingDeg: Float? = null,
+ val altitudeM: Double? = null,
+ val recordedAt: String? = null,
+ val source: String = AppPrefs.DEFAULT_SOURCE
+) {
+ fun toJson(): String {
+ val json = JSONObject()
+ json.put("latitude", latitude)
+ json.put("longitude", longitude)
+ speedKmh?.let { json.put("speed_kmh", it) }
+ accuracyM?.let { json.put("accuracy_m", it) }
+ headingDeg?.let { json.put("heading_deg", it) }
+ altitudeM?.let { json.put("altitude_m", it) }
+ recordedAt?.let { json.put("recorded_at", it) }
+ json.put("source", source)
+ return json.toString()
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
new file mode 100644
index 0000000..d6e3b7e
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
@@ -0,0 +1,99 @@
+package top.outsidethebox.followme.data
+
+import android.content.Context
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+data class IngestResult(
+ val success: Boolean,
+ val usedTransport: String,
+ val responseBody: String? = null,
+ val errorMessage: String? = null
+)
+
+class IngestRepository(context: Context) {
+ private val prefs = AppPrefs(context.applicationContext)
+ private val jsonType = "application/json; charset=utf-8".toMediaType()
+
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ fun send(payload: GpsPayload): IngestResult {
+ val token = prefs.getToken().trim()
+ if (token.isEmpty()) {
+ return IngestResult(false, prefs.getPreferredTransport(), errorMessage = "Missing device token")
+ }
+
+ val preferred = prefs.getPreferredTransport()
+ return if (preferred == AppPrefs.TRANSPORT_HTTP) {
+ sendHttpOnly(token, payload)
+ } else {
+ sendHttpsThenFallback(token, payload)
+ }
+ }
+
+ private fun sendHttpsThenFallback(token: String, payload: GpsPayload): IngestResult {
+ val httpsResult = post(AppPrefs.HTTPS_ENDPOINT, token, payload)
+ if (httpsResult.success) {
+ prefs.resetHttpsFallbackCounter()
+ prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTPS)
+ return httpsResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTPS)
+ }
+
+ val httpResult = post(AppPrefs.HTTP_ENDPOINT, token, payload)
+ if (httpResult.success) {
+ val newCount = prefs.getConsecutiveHttpsFallbacks() + 1
+ prefs.setConsecutiveHttpsFallbacks(newCount)
+ if (newCount >= 3) {
+ prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTP)
+ }
+ return httpResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
+ }
+
+ return IngestResult(
+ success = false,
+ usedTransport = AppPrefs.TRANSPORT_HTTPS,
+ errorMessage = httpsResult.errorMessage ?: httpResult.errorMessage
+ )
+ }
+
+ private fun sendHttpOnly(token: String, payload: GpsPayload): IngestResult {
+ val result = post(AppPrefs.HTTP_ENDPOINT, token, payload)
+ return if (result.success) {
+ result.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
+ } else {
+ result
+ }
+ }
+
+ private fun post(url: String, token: String, payload: GpsPayload): IngestResult {
+ val body = payload.toJson().toRequestBody(jsonType)
+ val request = Request.Builder()
+ .url(url)
+ .post(body)
+ .header("X-API-Key", token)
+ .header("Content-Type", "application/json")
+ .build()
+
+ return try {
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (response.isSuccessful) {
+ IngestResult(true, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text)
+ } else {
+ IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text, "HTTP ${response.code}")
+ }
+ }
+ } catch (e: IOException) {
+ IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, errorMessage = e.message)
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
new file mode 100644
index 0000000..a9571c9
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
@@ -0,0 +1,6 @@
+package top.outsidethebox.followme.location
+
+enum class TrackingMode(val intervalMs: Long) {
+ MOVING(10_000L),
+ IDLE(300_000L)
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
new file mode 100644
index 0000000..98d0a3f
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
@@ -0,0 +1,18 @@
+package top.outsidethebox.followme.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import top.outsidethebox.followme.data.AppPrefs
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ val action = intent?.action ?: return
+ if (action == Intent.ACTION_BOOT_COMPLETED || action == Intent.ACTION_MY_PACKAGE_REPLACED) {
+ val prefs = AppPrefs(context.applicationContext)
+ if (prefs.isTrackingEnabled()) {
+ TrackingService.start(context)
+ }
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
new file mode 100644
index 0000000..37c1c78
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
@@ -0,0 +1,298 @@
+package top.outsidethebox.followme.service
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationCallback: LocationCallback? = null
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ } else {
+ 0
+ }
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val playServicesOk =
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
+
+ if (playServicesOk) {
+ try {
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(
+ request,
+ locationCallback!!,
+ mainLooper
+ )
+
+ Log.d(TAG, "Using FusedLocationProviderClient")
+ return
+ } catch (e: Exception) {
+ Log.w(TAG, "Fused location failed, falling back to LocationManager", e)
+ }
+ } else {
+ Log.w(TAG, "Google Play Services unavailable, using LocationManager fallback")
+ }
+
+ startLegacyGpsFallback()
+ }
+
+ @Suppress("MissingPermission")
+ private fun startLegacyGpsFallback() {
+ val lm = locationManager ?: return
+
+ val minTimeMs = 15000L
+ val minDistanceM = 10f
+
+ gpsLocationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+ override fun onProviderEnabled(provider: String) {}
+ override fun onProviderDisabled(provider: String) {}
+ }
+
+ var requested = false
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested GPS_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "GPS_PROVIDER request failed", e)
+ }
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested NETWORK_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "NETWORK_PROVIDER request failed", e)
+ }
+
+ if (!requested) {
+ Log.w(TAG, "No location providers available for fallback")
+ }
+ }
+
+ private fun stopLocationUpdates() {
+ try {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ } catch (_: Exception) {
+ }
+ locationCallback = null
+
+ try {
+ gpsLocationListener?.let { listener ->
+ locationManager?.removeUpdates(listener)
+ }
+ } catch (_: Exception) {
+ }
+ gpsLocationListener = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.millisToIsoUtc(location.time),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val TAG = "TrackingService"
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412 b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620 b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557 b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616 b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
new file mode 100644
index 0000000..952e654
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
@@ -0,0 +1,108 @@
+package top.outsidethebox.followme.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.databinding.ActivityMainBinding
+import top.outsidethebox.followme.service.TrackingService
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var prefs: AppPrefs
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { refreshUi() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ prefs = AppPrefs(applicationContext)
+
+ binding.endpointView.text = buildString {
+ append("Primary: ")
+ append(AppPrefs.HTTPS_ENDPOINT)
+ append("\nFallback: ")
+ append(AppPrefs.HTTP_ENDPOINT)
+ }
+
+ populateFields()
+ wireActions()
+ refreshUi()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUi()
+ }
+
+ private fun populateFields() {
+ binding.tokenInput.setText(prefs.getToken())
+ binding.roleInput.setText(prefs.getRole())
+ binding.networkInput.setText(prefs.getNetworkNote())
+ binding.sourceInput.setText(prefs.getSource())
+ }
+
+ private fun wireActions() {
+ binding.saveButton.setOnClickListener {
+ saveSettings()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ refreshUi()
+ }
+
+ binding.startButton.setOnClickListener {
+ saveSettings()
+ if (!hasBaseLocationPermission()) {
+ requestPermissions()
+ return@setOnClickListener
+ }
+ TrackingService.start(this)
+ prefs.setTrackingEnabled(true)
+ refreshUi()
+ }
+
+ binding.stopButton.setOnClickListener {
+ TrackingService.stop(this)
+ prefs.setTrackingEnabled(false)
+ refreshUi()
+ }
+ }
+
+ private fun saveSettings() {
+ prefs.setToken(binding.tokenInput.text?.toString()?.trim().orEmpty())
+ prefs.setRole(binding.roleInput.text?.toString()?.trim().orEmpty())
+ prefs.setNetworkNote(binding.networkInput.text?.toString()?.trim().orEmpty())
+ prefs.setSource(binding.sourceInput.text?.toString()?.trim().orEmpty().ifBlank { AppPrefs.DEFAULT_SOURCE })
+ }
+
+ private fun refreshUi() {
+ val protocol = prefs.getPreferredTransport().uppercase()
+ binding.protocolView.text = "Preferred protocol: $protocol (HTTPS fallback count: ${prefs.getConsecutiveHttpsFallbacks()})"
+ binding.lastUploadView.text = "Last upload: ${prefs.getLastUpload()}"
+ binding.statusView.text = if (prefs.isTrackingEnabled()) "Status: running" else "Status: stopped"
+ }
+
+ private fun hasBaseLocationPermission(): Boolean {
+ val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun requestPermissions() {
+ val permissions = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ permissionLauncher.launch(permissions.toTypedArray())
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644 b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
new file mode 100644
index 0000000..2f6253b
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
@@ -0,0 +1,108 @@
+package top.outsidethebox.followme.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.databinding.ActivityMainBinding
+import top.outsidethebox.followme.service.TrackingService
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var prefs: AppPrefs
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { refreshUi() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ prefs = AppPrefs(applicationContext)
+
+ binding.endpointView.text = buildString {
+ append("Primary: ")
+ append(AppPrefs.HTTPS_ENDPOINT)
+ append("\nFallback: ")
+ append(AppPrefs.HTTP_ENDPOINT)
+ }
+
+ populateFields()
+ wireActions()
+ refreshUi()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUi()
+ }
+
+ private fun populateFields() {
+ binding.tokenInput.setText(prefs.getToken())
+ binding.roleInput.setText(prefs.getRole())
+ binding.networkInput.setText(prefs.getNetworkNote())
+ binding.sourceInput.setText(prefs.getSource())
+ }
+
+ private fun wireActions() {
+ binding.saveButton.setOnClickListener {
+ saveSettings()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ refreshUi()
+ }
+
+ binding.startButton.setOnClickListener {
+ saveSettings()
+ if (!hasBaseLocationPermission()) {
+ requestPermissions()
+ return@setOnClickListener
+ }
+ TrackingService.start(this)
+ prefs.setTrackingEnabled(true)
+ refreshUi()
+ }
+
+ binding.stopButton.setOnClickListener {
+ TrackingService.stop(this)
+ prefs.setTrackingEnabled(false)
+ refreshUi()
+ }
+ }
+
+ private fun saveSettings() {
+ prefs.setToken(binding.tokenInput.text?.toString()?.trim().orEmpty())
+ prefs.setRole(binding.roleInput.text?.toString()?.trim().orEmpty())
+ prefs.setNetworkNote(binding.networkInput.text?.toString()?.trim().orEmpty())
+ prefs.setSource(binding.sourceInput.text?.toString()?.trim().ifBlank { AppPrefs.DEFAULT_SOURCE })
+ }
+
+ private fun refreshUi() {
+ val protocol = prefs.getPreferredTransport().uppercase()
+ binding.protocolView.text = "Preferred protocol: $protocol (HTTPS fallback count: ${prefs.getConsecutiveHttpsFallbacks()})"
+ binding.lastUploadView.text = "Last upload: ${prefs.getLastUpload()}"
+ binding.statusView.text = if (prefs.isTrackingEnabled()) "Status: running" else "Status: stopped"
+ }
+
+ private fun hasBaseLocationPermission(): Boolean {
+ val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun requestPermissions() {
+ val permissions = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ permissionLauncher.launch(permissions.toTypedArray())
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
new file mode 100644
index 0000000..e01ddfb
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
@@ -0,0 +1,29 @@
+package top.outsidethebox.followme.util
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+object TimeUtils {
+ private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ private val prettyLocalFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+
+ @Synchronized
+ fun nowIsoUtc(): String {
+ return isoFormatter.format(Date())
+ }
+
+ @Synchronized
+ fun millisToIsoUtc(millis: Long): String {
+ return isoFormatter.format(Date(millis))
+ }
+
+ @Synchronized
+ fun nowPrettyLocal(): String {
+ return prettyLocalFormatter.format(Date())
+ }
+}
diff --git a/app.bak.20260330-214820/src/main/res/drawable/ic_launcher_foreground.png b/app.bak.20260330-214820/src/main/res/drawable/ic_launcher_foreground.png
new file mode 100644
index 0000000..2860387
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/drawable/ic_launcher_foreground.png differ
diff --git a/app.bak.20260330-214820/src/main/res/drawable/otb_logo.png b/app.bak.20260330-214820/src/main/res/drawable/otb_logo.png
new file mode 100644
index 0000000..dd9f3fa
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/drawable/otb_logo.png differ
diff --git a/app.bak.20260330-214820/src/main/res/layout/activity_main.xml b/app.bak.20260330-214820/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..2ed98ab
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/res/layout/activity_main.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher.png b/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..859d5c5
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..859d5c5
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher.png b/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..3735675
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..3735675
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher.png b/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..b4442ee
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b4442ee
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..2ceebd1
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..2ceebd1
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..38bb200
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..38bb200
Binary files /dev/null and b/app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app.bak.20260330-214820/src/main/res/values/colors.xml b/app.bak.20260330-214820/src/main/res/values/colors.xml
new file mode 100644
index 0000000..ab35b9f
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+ #455A64
+ #263238
+ #80CBC4
+ #FFFFFF
+ #000000
+
diff --git a/app.bak.20260330-214820/src/main/res/values/strings.xml b/app.bak.20260330-214820/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1c75390
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+ follow-me
+ Tracking
+ otb-tcom tracking active
+ Posting location updates to follow-me
+
diff --git a/app.bak.20260330-214820/src/main/res/values/themes.xml b/app.bak.20260330-214820/src/main/res/values/themes.xml
new file mode 100644
index 0000000..da57f2d
--- /dev/null
+++ b/app.bak.20260330-214820/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app.bak.identity.20260330-222009/build.gradle.kts b/app.bak.identity.20260330-222009/build.gradle.kts
new file mode 100644
index 0000000..6cc823e
--- /dev/null
+++ b/app.bak.identity.20260330-222009/build.gradle.kts
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "top.outsidethebox.followme"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "top.outsidethebox.followme"
+ minSdk = 23
+ targetSdk = 34
+ versionCode = 4
+ versionName = "0.4.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
+
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.11.0")
+ implementation("androidx.activity:activity-ktx:1.8.2")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.lifecycle:lifecycle-service:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("com.google.android.gms:play-services-location:21.2.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+}
diff --git a/app.bak.identity.20260330-222009/proguard-rules.pro b/app.bak.identity.20260330-222009/proguard-rules.pro
new file mode 100644
index 0000000..a1e5cc2
--- /dev/null
+++ b/app.bak.identity.20260330-222009/proguard-rules.pro
@@ -0,0 +1 @@
+# No custom rules yet.
diff --git a/app.bak.identity.20260330-222009/src/main/AndroidManifest.xml b/app.bak.identity.20260330-222009/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..eaba08c
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
new file mode 100644
index 0000000..1b9dc72
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
@@ -0,0 +1,52 @@
+package top.outsidethebox.followme.data
+
+import android.content.Context
+import android.content.SharedPreferences
+
+class AppPrefs(context: Context) {
+ private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
+
+ fun getToken(): String = prefs.getString(KEY_TOKEN, "") ?: ""
+ fun setToken(value: String) = prefs.edit().putString(KEY_TOKEN, value).apply()
+
+ fun getRole(): String = prefs.getString(KEY_ROLE, "drone") ?: "drone"
+ fun setRole(value: String) = prefs.edit().putString(KEY_ROLE, value).apply()
+
+ fun getNetworkNote(): String = prefs.getString(KEY_NETWORK_NOTE, "") ?: ""
+ fun setNetworkNote(value: String) = prefs.edit().putString(KEY_NETWORK_NOTE, value).apply()
+
+ fun getSource(): String = prefs.getString(KEY_SOURCE, DEFAULT_SOURCE) ?: DEFAULT_SOURCE
+ fun setSource(value: String) = prefs.edit().putString(KEY_SOURCE, value).apply()
+
+ fun isTrackingEnabled(): Boolean = prefs.getBoolean(KEY_TRACKING_ENABLED, false)
+ fun setTrackingEnabled(value: Boolean) = prefs.edit().putBoolean(KEY_TRACKING_ENABLED, value).apply()
+
+ fun getConsecutiveHttpsFallbacks(): Int = prefs.getInt(KEY_HTTPS_FALLBACKS, 0)
+ fun setConsecutiveHttpsFallbacks(value: Int) = prefs.edit().putInt(KEY_HTTPS_FALLBACKS, value).apply()
+
+ fun getPreferredTransport(): String = prefs.getString(KEY_PREFERRED_TRANSPORT, TRANSPORT_HTTPS) ?: TRANSPORT_HTTPS
+ fun setPreferredTransport(value: String) = prefs.edit().putString(KEY_PREFERRED_TRANSPORT, value).apply()
+
+ fun getLastUpload(): String = prefs.getString(KEY_LAST_UPLOAD, "never") ?: "never"
+ fun setLastUpload(value: String) = prefs.edit().putString(KEY_LAST_UPLOAD, value).apply()
+
+ fun resetHttpsFallbackCounter() = setConsecutiveHttpsFallbacks(0)
+
+ companion object {
+ private const val PREF_NAME = "follow_me_prefs"
+ private const val KEY_TOKEN = "token"
+ private const val KEY_ROLE = "role"
+ private const val KEY_NETWORK_NOTE = "network_note"
+ private const val KEY_SOURCE = "source"
+ private const val KEY_TRACKING_ENABLED = "tracking_enabled"
+ private const val KEY_HTTPS_FALLBACKS = "https_fallbacks"
+ private const val KEY_PREFERRED_TRANSPORT = "preferred_transport"
+ private const val KEY_LAST_UPLOAD = "last_upload"
+
+ const val TRANSPORT_HTTPS = "https"
+ const val TRANSPORT_HTTP = "http"
+ const val DEFAULT_SOURCE = "otb-tcom"
+ const val HTTPS_ENDPOINT = "https://otb-tracker.outsidethebox.top/api/gps-ingest"
+ const val HTTP_ENDPOINT = "http://otb-tracker.outsidethebox.top/api/gps-ingest"
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
new file mode 100644
index 0000000..b91dad8
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
@@ -0,0 +1,27 @@
+package top.outsidethebox.followme.data
+
+import org.json.JSONObject
+
+data class GpsPayload(
+ val latitude: Double,
+ val longitude: Double,
+ val speedKmh: Double? = null,
+ val accuracyM: Float? = null,
+ val headingDeg: Float? = null,
+ val altitudeM: Double? = null,
+ val recordedAt: String? = null,
+ val source: String = AppPrefs.DEFAULT_SOURCE
+) {
+ fun toJson(): String {
+ val json = JSONObject()
+ json.put("latitude", latitude)
+ json.put("longitude", longitude)
+ speedKmh?.let { json.put("speed_kmh", it) }
+ accuracyM?.let { json.put("accuracy_m", it) }
+ headingDeg?.let { json.put("heading_deg", it) }
+ altitudeM?.let { json.put("altitude_m", it) }
+ recordedAt?.let { json.put("recorded_at", it) }
+ json.put("source", source)
+ return json.toString()
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
new file mode 100644
index 0000000..d6e3b7e
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
@@ -0,0 +1,99 @@
+package top.outsidethebox.followme.data
+
+import android.content.Context
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.io.IOException
+import java.util.concurrent.TimeUnit
+
+data class IngestResult(
+ val success: Boolean,
+ val usedTransport: String,
+ val responseBody: String? = null,
+ val errorMessage: String? = null
+)
+
+class IngestRepository(context: Context) {
+ private val prefs = AppPrefs(context.applicationContext)
+ private val jsonType = "application/json; charset=utf-8".toMediaType()
+
+ private val client: OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(10, TimeUnit.SECONDS)
+ .readTimeout(15, TimeUnit.SECONDS)
+ .writeTimeout(15, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(true)
+ .build()
+
+ fun send(payload: GpsPayload): IngestResult {
+ val token = prefs.getToken().trim()
+ if (token.isEmpty()) {
+ return IngestResult(false, prefs.getPreferredTransport(), errorMessage = "Missing device token")
+ }
+
+ val preferred = prefs.getPreferredTransport()
+ return if (preferred == AppPrefs.TRANSPORT_HTTP) {
+ sendHttpOnly(token, payload)
+ } else {
+ sendHttpsThenFallback(token, payload)
+ }
+ }
+
+ private fun sendHttpsThenFallback(token: String, payload: GpsPayload): IngestResult {
+ val httpsResult = post(AppPrefs.HTTPS_ENDPOINT, token, payload)
+ if (httpsResult.success) {
+ prefs.resetHttpsFallbackCounter()
+ prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTPS)
+ return httpsResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTPS)
+ }
+
+ val httpResult = post(AppPrefs.HTTP_ENDPOINT, token, payload)
+ if (httpResult.success) {
+ val newCount = prefs.getConsecutiveHttpsFallbacks() + 1
+ prefs.setConsecutiveHttpsFallbacks(newCount)
+ if (newCount >= 3) {
+ prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTP)
+ }
+ return httpResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
+ }
+
+ return IngestResult(
+ success = false,
+ usedTransport = AppPrefs.TRANSPORT_HTTPS,
+ errorMessage = httpsResult.errorMessage ?: httpResult.errorMessage
+ )
+ }
+
+ private fun sendHttpOnly(token: String, payload: GpsPayload): IngestResult {
+ val result = post(AppPrefs.HTTP_ENDPOINT, token, payload)
+ return if (result.success) {
+ result.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
+ } else {
+ result
+ }
+ }
+
+ private fun post(url: String, token: String, payload: GpsPayload): IngestResult {
+ val body = payload.toJson().toRequestBody(jsonType)
+ val request = Request.Builder()
+ .url(url)
+ .post(body)
+ .header("X-API-Key", token)
+ .header("Content-Type", "application/json")
+ .build()
+
+ return try {
+ client.newCall(request).execute().use { response ->
+ val text = response.body?.string().orEmpty()
+ if (response.isSuccessful) {
+ IngestResult(true, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text)
+ } else {
+ IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text, "HTTP ${response.code}")
+ }
+ }
+ } catch (e: IOException) {
+ IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, errorMessage = e.message)
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
new file mode 100644
index 0000000..a9571c9
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
@@ -0,0 +1,6 @@
+package top.outsidethebox.followme.location
+
+enum class TrackingMode(val intervalMs: Long) {
+ MOVING(10_000L),
+ IDLE(300_000L)
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
new file mode 100644
index 0000000..98d0a3f
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
@@ -0,0 +1,18 @@
+package top.outsidethebox.followme.service
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import top.outsidethebox.followme.data.AppPrefs
+
+class BootReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent?) {
+ val action = intent?.action ?: return
+ if (action == Intent.ACTION_BOOT_COMPLETED || action == Intent.ACTION_MY_PACKAGE_REPLACED) {
+ val prefs = AppPrefs(context.applicationContext)
+ if (prefs.isTrackingEnabled()) {
+ TrackingService.start(context)
+ }
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
new file mode 100644
index 0000000..37c1c78
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
@@ -0,0 +1,298 @@
+package top.outsidethebox.followme.service
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationCallback: LocationCallback? = null
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ } else {
+ 0
+ }
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val playServicesOk =
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
+
+ if (playServicesOk) {
+ try {
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(
+ request,
+ locationCallback!!,
+ mainLooper
+ )
+
+ Log.d(TAG, "Using FusedLocationProviderClient")
+ return
+ } catch (e: Exception) {
+ Log.w(TAG, "Fused location failed, falling back to LocationManager", e)
+ }
+ } else {
+ Log.w(TAG, "Google Play Services unavailable, using LocationManager fallback")
+ }
+
+ startLegacyGpsFallback()
+ }
+
+ @Suppress("MissingPermission")
+ private fun startLegacyGpsFallback() {
+ val lm = locationManager ?: return
+
+ val minTimeMs = 15000L
+ val minDistanceM = 10f
+
+ gpsLocationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+ override fun onProviderEnabled(provider: String) {}
+ override fun onProviderDisabled(provider: String) {}
+ }
+
+ var requested = false
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested GPS_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "GPS_PROVIDER request failed", e)
+ }
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested NETWORK_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "NETWORK_PROVIDER request failed", e)
+ }
+
+ if (!requested) {
+ Log.w(TAG, "No location providers available for fallback")
+ }
+ }
+
+ private fun stopLocationUpdates() {
+ try {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ } catch (_: Exception) {
+ }
+ locationCallback = null
+
+ try {
+ gpsLocationListener?.let { listener ->
+ locationManager?.removeUpdates(listener)
+ }
+ } catch (_: Exception) {
+ }
+ gpsLocationListener = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.millisToIsoUtc(location.time),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val TAG = "TrackingService"
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412 b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620 b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557 b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616 b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
new file mode 100644
index 0000000..952e654
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
@@ -0,0 +1,108 @@
+package top.outsidethebox.followme.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.databinding.ActivityMainBinding
+import top.outsidethebox.followme.service.TrackingService
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var prefs: AppPrefs
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { refreshUi() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ prefs = AppPrefs(applicationContext)
+
+ binding.endpointView.text = buildString {
+ append("Primary: ")
+ append(AppPrefs.HTTPS_ENDPOINT)
+ append("\nFallback: ")
+ append(AppPrefs.HTTP_ENDPOINT)
+ }
+
+ populateFields()
+ wireActions()
+ refreshUi()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUi()
+ }
+
+ private fun populateFields() {
+ binding.tokenInput.setText(prefs.getToken())
+ binding.roleInput.setText(prefs.getRole())
+ binding.networkInput.setText(prefs.getNetworkNote())
+ binding.sourceInput.setText(prefs.getSource())
+ }
+
+ private fun wireActions() {
+ binding.saveButton.setOnClickListener {
+ saveSettings()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ refreshUi()
+ }
+
+ binding.startButton.setOnClickListener {
+ saveSettings()
+ if (!hasBaseLocationPermission()) {
+ requestPermissions()
+ return@setOnClickListener
+ }
+ TrackingService.start(this)
+ prefs.setTrackingEnabled(true)
+ refreshUi()
+ }
+
+ binding.stopButton.setOnClickListener {
+ TrackingService.stop(this)
+ prefs.setTrackingEnabled(false)
+ refreshUi()
+ }
+ }
+
+ private fun saveSettings() {
+ prefs.setToken(binding.tokenInput.text?.toString()?.trim().orEmpty())
+ prefs.setRole(binding.roleInput.text?.toString()?.trim().orEmpty())
+ prefs.setNetworkNote(binding.networkInput.text?.toString()?.trim().orEmpty())
+ prefs.setSource(binding.sourceInput.text?.toString()?.trim().orEmpty().ifBlank { AppPrefs.DEFAULT_SOURCE })
+ }
+
+ private fun refreshUi() {
+ val protocol = prefs.getPreferredTransport().uppercase()
+ binding.protocolView.text = "Preferred protocol: $protocol (HTTPS fallback count: ${prefs.getConsecutiveHttpsFallbacks()})"
+ binding.lastUploadView.text = "Last upload: ${prefs.getLastUpload()}"
+ binding.statusView.text = if (prefs.isTrackingEnabled()) "Status: running" else "Status: stopped"
+ }
+
+ private fun hasBaseLocationPermission(): Boolean {
+ val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun requestPermissions() {
+ val permissions = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ permissionLauncher.launch(permissions.toTypedArray())
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644 b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
new file mode 100644
index 0000000..2f6253b
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
@@ -0,0 +1,108 @@
+package top.outsidethebox.followme.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.databinding.ActivityMainBinding
+import top.outsidethebox.followme.service.TrackingService
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var prefs: AppPrefs
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { refreshUi() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ prefs = AppPrefs(applicationContext)
+
+ binding.endpointView.text = buildString {
+ append("Primary: ")
+ append(AppPrefs.HTTPS_ENDPOINT)
+ append("\nFallback: ")
+ append(AppPrefs.HTTP_ENDPOINT)
+ }
+
+ populateFields()
+ wireActions()
+ refreshUi()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUi()
+ }
+
+ private fun populateFields() {
+ binding.tokenInput.setText(prefs.getToken())
+ binding.roleInput.setText(prefs.getRole())
+ binding.networkInput.setText(prefs.getNetworkNote())
+ binding.sourceInput.setText(prefs.getSource())
+ }
+
+ private fun wireActions() {
+ binding.saveButton.setOnClickListener {
+ saveSettings()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ refreshUi()
+ }
+
+ binding.startButton.setOnClickListener {
+ saveSettings()
+ if (!hasBaseLocationPermission()) {
+ requestPermissions()
+ return@setOnClickListener
+ }
+ TrackingService.start(this)
+ prefs.setTrackingEnabled(true)
+ refreshUi()
+ }
+
+ binding.stopButton.setOnClickListener {
+ TrackingService.stop(this)
+ prefs.setTrackingEnabled(false)
+ refreshUi()
+ }
+ }
+
+ private fun saveSettings() {
+ prefs.setToken(binding.tokenInput.text?.toString()?.trim().orEmpty())
+ prefs.setRole(binding.roleInput.text?.toString()?.trim().orEmpty())
+ prefs.setNetworkNote(binding.networkInput.text?.toString()?.trim().orEmpty())
+ prefs.setSource(binding.sourceInput.text?.toString()?.trim().ifBlank { AppPrefs.DEFAULT_SOURCE })
+ }
+
+ private fun refreshUi() {
+ val protocol = prefs.getPreferredTransport().uppercase()
+ binding.protocolView.text = "Preferred protocol: $protocol (HTTPS fallback count: ${prefs.getConsecutiveHttpsFallbacks()})"
+ binding.lastUploadView.text = "Last upload: ${prefs.getLastUpload()}"
+ binding.statusView.text = if (prefs.isTrackingEnabled()) "Status: running" else "Status: stopped"
+ }
+
+ private fun hasBaseLocationPermission(): Boolean {
+ val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun requestPermissions() {
+ val permissions = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ permissionLauncher.launch(permissions.toTypedArray())
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
new file mode 100644
index 0000000..e01ddfb
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
@@ -0,0 +1,29 @@
+package top.outsidethebox.followme.util
+
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+
+object TimeUtils {
+ private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ private val prettyLocalFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
+
+ @Synchronized
+ fun nowIsoUtc(): String {
+ return isoFormatter.format(Date())
+ }
+
+ @Synchronized
+ fun millisToIsoUtc(millis: Long): String {
+ return isoFormatter.format(Date(millis))
+ }
+
+ @Synchronized
+ fun nowPrettyLocal(): String {
+ return prettyLocalFormatter.format(Date())
+ }
+}
diff --git a/app.bak.identity.20260330-222009/src/main/res/drawable/ic_launcher_foreground.png b/app.bak.identity.20260330-222009/src/main/res/drawable/ic_launcher_foreground.png
new file mode 100644
index 0000000..2860387
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/drawable/ic_launcher_foreground.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/drawable/otb_logo.png b/app.bak.identity.20260330-222009/src/main/res/drawable/otb_logo.png
new file mode 100644
index 0000000..dd9f3fa
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/drawable/otb_logo.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/layout/activity_main.xml b/app.bak.identity.20260330-222009/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..2ed98ab
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/res/layout/activity_main.xml
@@ -0,0 +1,160 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..859d5c5
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..859d5c5
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..3735675
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..3735675
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..b4442ee
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..b4442ee
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..2ceebd1
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..2ceebd1
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..38bb200
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..38bb200
Binary files /dev/null and b/app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app.bak.identity.20260330-222009/src/main/res/values/colors.xml b/app.bak.identity.20260330-222009/src/main/res/values/colors.xml
new file mode 100644
index 0000000..ab35b9f
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+ #455A64
+ #263238
+ #80CBC4
+ #FFFFFF
+ #000000
+
diff --git a/app.bak.identity.20260330-222009/src/main/res/values/strings.xml b/app.bak.identity.20260330-222009/src/main/res/values/strings.xml
new file mode 100644
index 0000000..1c75390
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+ follow-me
+ Tracking
+ otb-tcom tracking active
+ Posting location updates to follow-me
+
diff --git a/app.bak.identity.20260330-222009/src/main/res/values/themes.xml b/app.bak.identity.20260330-222009/src/main/res/values/themes.xml
new file mode 100644
index 0000000..da57f2d
--- /dev/null
+++ b/app.bak.identity.20260330-222009/src/main/res/values/themes.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 17c9064..d2f6f42 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -11,8 +11,8 @@ android {
applicationId = "top.outsidethebox.followme"
minSdk = 23
targetSdk = 34
- versionCode = 1
- versionName = "0.1.0"
+ versionCode = 5
+ versionName = "0.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -25,11 +25,15 @@ android {
"proguard-rules.pro"
)
}
+ debug {
+ isMinifyEnabled = false
+ }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@@ -42,6 +46,8 @@ android {
}
dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
+
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.11.0")
diff --git a/app/build.gradle.kts.bak.fix.20260330-222351 b/app/build.gradle.kts.bak.fix.20260330-222351
new file mode 100644
index 0000000..6cc823e
--- /dev/null
+++ b/app/build.gradle.kts.bak.fix.20260330-222351
@@ -0,0 +1,61 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+
+android {
+ namespace = "top.outsidethebox.followme"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "top.outsidethebox.followme"
+ minSdk = 23
+ targetSdk = 34
+ versionCode = 4
+ versionName = "0.4.0"
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isMinifyEnabled = false
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ isCoreLibraryDesugaringEnabled = true
+ }
+
+ kotlinOptions {
+ jvmTarget = "17"
+ }
+
+ buildFeatures {
+ viewBinding = true
+ }
+}
+
+dependencies {
+ coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
+
+ implementation("androidx.core:core-ktx:1.12.0")
+ implementation("androidx.appcompat:appcompat:1.7.0")
+ implementation("com.google.android.material:material:1.11.0")
+ implementation("androidx.activity:activity-ktx:1.8.2")
+ implementation("androidx.constraintlayout:constraintlayout:2.1.4")
+ implementation("androidx.lifecycle:lifecycle-service:2.7.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
+ implementation("com.google.android.gms:play-services-location:21.2.0")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 21ca131..eaba08c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,16 +5,15 @@
-
diff --git a/app/src/main/java/top/outsidethebox/followme/DeviceIdentity.kt b/app/src/main/java/top/outsidethebox/followme/DeviceIdentity.kt
new file mode 100644
index 0000000..02530a3
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/DeviceIdentity.kt
@@ -0,0 +1,39 @@
+package top.outsidethebox.followme
+
+import android.content.Context
+import android.provider.Settings
+import java.util.UUID
+
+object DeviceIdentity {
+ fun get(context: Context): Triple {
+ val prefs = context.getSharedPreferences("followme_prefs", Context.MODE_PRIVATE)
+
+ var uuid = prefs.getString("device_uuid", null)
+ if (uuid.isNullOrBlank()) {
+ uuid = UUID.randomUUID().toString()
+ prefs.edit().putString("device_uuid", uuid).apply()
+ }
+
+ val androidId = Settings.Secure.getString(
+ context.contentResolver,
+ Settings.Secure.ANDROID_ID
+ ) ?: ""
+
+ val manufacturer = android.os.Build.MANUFACTURER.orEmpty().trim()
+ val model = android.os.Build.MODEL.orEmpty().trim()
+ val shortId = uuid.takeLast(4)
+
+ val baseName = listOf(manufacturer, model)
+ .filter { it.isNotBlank() }
+ .joinToString("-")
+ .replace("\\s+".toRegex(), "-")
+
+ val deviceName = if (baseName.isBlank()) {
+ "android-$shortId"
+ } else {
+ "$baseName-$shortId"
+ }
+
+ return Triple(uuid, androidId, deviceName)
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt b/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
index b91dad8..b6a5d15 100644
--- a/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
+++ b/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
@@ -1,8 +1,11 @@
package top.outsidethebox.followme.data
+import android.content.Context
import org.json.JSONObject
+import top.outsidethebox.followme.DeviceIdentity
data class GpsPayload(
+ val context: Context,
val latitude: Double,
val longitude: Double,
val speedKmh: Double? = null,
@@ -14,14 +17,21 @@ data class GpsPayload(
) {
fun toJson(): String {
val json = JSONObject()
+ val (deviceUuid, androidId, deviceName) = DeviceIdentity.get(context)
+
json.put("latitude", latitude)
json.put("longitude", longitude)
+ json.put("device_uuid", deviceUuid)
+ json.put("android_id", androidId)
+ json.put("device_name", deviceName)
+
speedKmh?.let { json.put("speed_kmh", it) }
accuracyM?.let { json.put("accuracy_m", it) }
headingDeg?.let { json.put("heading_deg", it) }
altitudeM?.let { json.put("altitude_m", it) }
recordedAt?.let { json.put("recorded_at", it) }
json.put("source", source)
+
return json.toString()
}
}
diff --git a/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt.bak.fix.20260330-222351 b/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt.bak.fix.20260330-222351
new file mode 100644
index 0000000..65d730b
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt.bak.fix.20260330-222351
@@ -0,0 +1,34 @@
+import top.outsidethebox.followme.DeviceIdentity
+import android.content.Context
+package top.outsidethebox.followme.data
+
+import org.json.JSONObject
+
+data class GpsPayload(
+ val context: Context,
+ val latitude: Double,
+ val longitude: Double,
+ val speedKmh: Double? = null,
+ val accuracyM: Float? = null,
+ val headingDeg: Float? = null,
+ val altitudeM: Double? = null,
+ val recordedAt: String? = null,
+ val source: String = AppPrefs.DEFAULT_SOURCE
+) {
+ fun toJson(): String {
+ val json = JSONObject()
+ val (deviceUuid, androidId, deviceName) = DeviceIdentity.get(context)
+ json.put("device_uuid", deviceUuid)
+ json.put("android_id", androidId)
+ json.put("device_name", deviceName)
+ json.put("latitude", latitude)
+ json.put("longitude", longitude)
+ speedKmh?.let { json.put("speed_kmh", it) }
+ accuracyM?.let { json.put("accuracy_m", it) }
+ headingDeg?.let { json.put("heading_deg", it) }
+ altitudeM?.let { json.put("altitude_m", it) }
+ recordedAt?.let { json.put("recorded_at", it) }
+ json.put("source", source)
+ return json.toString()
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
index 9e95e45..8c7d99a 100644
--- a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
@@ -10,11 +10,17 @@ import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
import android.os.Build
+import android.os.Bundle
import android.os.IBinder
+import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
@@ -42,12 +48,15 @@ class TrackingService : Service() {
private var currentMode: TrackingMode = TrackingMode.MOVING
private var locationCallback: LocationCallback? = null
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
override fun onCreate() {
super.onCreate()
prefs = AppPrefs(applicationContext)
repository = IngestRepository(applicationContext)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
createNotificationChannel()
}
@@ -77,7 +86,7 @@ class TrackingService : Service() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text))
- .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent)
.setOngoing(true)
.build()
@@ -86,7 +95,11 @@ class TrackingService : Service() {
this,
NOTIFICATION_ID,
notification,
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ } else {
+ 0
+ }
)
}
@@ -96,28 +109,125 @@ class TrackingService : Service() {
stopLocationUpdates()
currentMode = mode
- val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, mode.intervalMs)
- .setMinUpdateIntervalMillis(mode.intervalMs)
- .setWaitForAccurateLocation(false)
- .build()
+ val playServicesOk =
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
+
+ if (playServicesOk) {
+ try {
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(
+ request,
+ locationCallback!!,
+ mainLooper
+ )
+
+ Log.d(TAG, "Using FusedLocationProviderClient")
+ return
+ } catch (e: Exception) {
+ Log.w(TAG, "Fused location failed, falling back to LocationManager", e)
+ }
+ } else {
+ Log.w(TAG, "Google Play Services unavailable, using LocationManager fallback")
+ }
+
+ startLegacyGpsFallback()
+ }
+
+ @Suppress("MissingPermission")
+ private fun startLegacyGpsFallback() {
+ val lm = locationManager ?: return
+
+ val minTimeMs = 15000L
+ val minDistanceM = 10f
- locationCallback = object : LocationCallback() {
- override fun onLocationResult(result: LocationResult) {
- val location = result.lastLocation ?: return
+ gpsLocationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
val nextMode = decideMode(location)
if (nextMode != currentMode) {
startLocationUpdates(nextMode)
+ return
}
uploadLocation(location)
}
+
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+ override fun onProviderEnabled(provider: String) {}
+ override fun onProviderDisabled(provider: String) {}
}
- fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ var requested = false
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested GPS_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "GPS_PROVIDER request failed", e)
+ }
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested NETWORK_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "NETWORK_PROVIDER request failed", e)
+ }
+
+ if (!requested) {
+ Log.w(TAG, "No location providers available for fallback")
+ }
}
private fun stopLocationUpdates() {
- locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ try {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ } catch (_: Exception) {
+ }
locationCallback = null
+
+ try {
+ gpsLocationListener?.let { listener ->
+ locationManager?.removeUpdates(listener)
+ }
+ } catch (_: Exception) {
+ }
+ gpsLocationListener = null
}
private fun decideMode(location: Location): TrackingMode {
@@ -128,13 +238,14 @@ class TrackingService : Service() {
private fun uploadLocation(location: Location) {
val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
val payload = GpsPayload(
+ context = this,
latitude = location.latitude,
longitude = location.longitude,
speedKmh = speedKmh,
accuracyM = if (location.hasAccuracy()) location.accuracy else null,
headingDeg = if (location.hasBearing()) location.bearing else null,
altitudeM = if (location.hasAltitude()) location.altitude else null,
- recordedAt = TimeUtils.nowIsoUtc(),
+ recordedAt = TimeUtils.millisToIsoUtc(location.time),
source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
)
@@ -168,6 +279,7 @@ class TrackingService : Service() {
}
companion object {
+ private const val TAG = "TrackingService"
private const val CHANNEL_ID = "follow_me_tracking"
private const val NOTIFICATION_ID = 7001
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412 b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620 b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557 b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616 b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
new file mode 100644
index 0000000..16d7ff2
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
@@ -0,0 +1,206 @@
+package top.outsidethebox.followme.service
+
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Bundle
+import android.util.Log
+import com.google.android.gms.common.GoogleApiAvailability
+import com.google.android.gms.common.ConnectionResult
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.os.Build
+import android.os.IBinder
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ companion object {
+ private const val TAG = "TrackingService"
+ }
+ private var locationCallback: LocationCallback? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(android.R.drawable.ic_menu_mylocation)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION else 0
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(request, locationCallback!!, mainLooper)
+ }
+
+ private fun stopLocationUpdates() {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ locationCallback = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.nowIsoUtc(),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.fix.20260330-222351 b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.fix.20260330-222351
new file mode 100644
index 0000000..8c7d99a
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.fix.20260330-222351
@@ -0,0 +1,299 @@
+package top.outsidethebox.followme.service
+
+import android.Manifest
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.location.Location
+import android.location.LocationListener
+import android.location.LocationManager
+import android.os.Build
+import android.os.Bundle
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import com.google.android.gms.common.ConnectionResult
+import com.google.android.gms.common.GoogleApiAvailability
+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.LocationServices
+import com.google.android.gms.location.Priority
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import top.outsidethebox.followme.R
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.data.GpsPayload
+import top.outsidethebox.followme.data.IngestRepository
+import top.outsidethebox.followme.location.TrackingMode
+import top.outsidethebox.followme.ui.MainActivity
+import top.outsidethebox.followme.util.TimeUtils
+
+class TrackingService : Service() {
+ private lateinit var fusedLocationClient: FusedLocationProviderClient
+ private lateinit var prefs: AppPrefs
+ private lateinit var repository: IngestRepository
+ private val scope = CoroutineScope(Dispatchers.IO + Job())
+
+ private var currentMode: TrackingMode = TrackingMode.MOVING
+ private var locationCallback: LocationCallback? = null
+ private var locationManager: LocationManager? = null
+ private var gpsLocationListener: LocationListener? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ prefs = AppPrefs(applicationContext)
+ repository = IngestRepository(applicationContext)
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
+ locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ prefs.setTrackingEnabled(true)
+ startAsForeground()
+ startLocationUpdates(currentMode)
+ return START_STICKY
+ }
+
+ override fun onDestroy() {
+ stopLocationUpdates()
+ scope.cancel()
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ private fun startAsForeground() {
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 1001,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
+ .setContentTitle(getString(R.string.notification_title))
+ .setContentText(getString(R.string.notification_text))
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentIntent(pendingIntent)
+ .setOngoing(true)
+ .build()
+
+ ServiceCompat.startForeground(
+ this,
+ NOTIFICATION_ID,
+ notification,
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
+ } else {
+ 0
+ }
+ )
+ }
+
+ private fun startLocationUpdates(mode: TrackingMode) {
+ if (!hasLocationPermission()) return
+
+ stopLocationUpdates()
+ currentMode = mode
+
+ val playServicesOk =
+ GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
+
+ if (playServicesOk) {
+ try {
+ val request = LocationRequest.Builder(
+ Priority.PRIORITY_HIGH_ACCURACY,
+ 15000L
+ )
+ .setMinUpdateIntervalMillis(5000L)
+ .setMinUpdateDistanceMeters(10f)
+ .setWaitForAccurateLocation(false)
+ .build()
+
+ locationCallback = object : LocationCallback() {
+ override fun onLocationResult(result: LocationResult) {
+ val location = result.lastLocation ?: return
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+ }
+
+ fusedLocationClient.requestLocationUpdates(
+ request,
+ locationCallback!!,
+ mainLooper
+ )
+
+ Log.d(TAG, "Using FusedLocationProviderClient")
+ return
+ } catch (e: Exception) {
+ Log.w(TAG, "Fused location failed, falling back to LocationManager", e)
+ }
+ } else {
+ Log.w(TAG, "Google Play Services unavailable, using LocationManager fallback")
+ }
+
+ startLegacyGpsFallback()
+ }
+
+ @Suppress("MissingPermission")
+ private fun startLegacyGpsFallback() {
+ val lm = locationManager ?: return
+
+ val minTimeMs = 15000L
+ val minDistanceM = 10f
+
+ gpsLocationListener = object : LocationListener {
+ override fun onLocationChanged(location: Location) {
+ val nextMode = decideMode(location)
+ if (nextMode != currentMode) {
+ startLocationUpdates(nextMode)
+ return
+ }
+ uploadLocation(location)
+ }
+
+ override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
+ override fun onProviderEnabled(provider: String) {}
+ override fun onProviderDisabled(provider: String) {}
+ }
+
+ var requested = false
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.GPS_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested GPS_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "GPS_PROVIDER request failed", e)
+ }
+
+ try {
+ if (lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
+ lm.requestLocationUpdates(
+ LocationManager.NETWORK_PROVIDER,
+ minTimeMs,
+ minDistanceM,
+ gpsLocationListener!!,
+ mainLooper
+ )
+ requested = true
+ Log.d(TAG, "Requested NETWORK_PROVIDER updates")
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "NETWORK_PROVIDER request failed", e)
+ }
+
+ if (!requested) {
+ Log.w(TAG, "No location providers available for fallback")
+ }
+ }
+
+ private fun stopLocationUpdates() {
+ try {
+ locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
+ } catch (_: Exception) {
+ }
+ locationCallback = null
+
+ try {
+ gpsLocationListener?.let { listener ->
+ locationManager?.removeUpdates(listener)
+ }
+ } catch (_: Exception) {
+ }
+ gpsLocationListener = null
+ }
+
+ private fun decideMode(location: Location): TrackingMode {
+ val speedKmh = if (location.hasSpeed()) location.speed * 3.6 else 0.0
+ return if (speedKmh >= 8.0) TrackingMode.MOVING else TrackingMode.IDLE
+ }
+
+ private fun uploadLocation(location: Location) {
+ val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
+ val payload = GpsPayload(
+ context = this,
+ latitude = location.latitude,
+ longitude = location.longitude,
+ speedKmh = speedKmh,
+ accuracyM = if (location.hasAccuracy()) location.accuracy else null,
+ headingDeg = if (location.hasBearing()) location.bearing else null,
+ altitudeM = if (location.hasAltitude()) location.altitude else null,
+ recordedAt = TimeUtils.millisToIsoUtc(location.time),
+ source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
+ )
+
+ scope.launch {
+ val result = repository.send(payload)
+ val status = if (result.success) {
+ "${TimeUtils.nowPrettyLocal()} via ${result.usedTransport.uppercase()}"
+ } else {
+ "failed ${TimeUtils.nowPrettyLocal()} (${result.errorMessage ?: "unknown"})"
+ }
+ prefs.setLastUpload(status)
+ }
+ }
+
+ private fun hasLocationPermission(): Boolean {
+ val fine = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ val channel = NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.notification_channel_name),
+ NotificationManager.IMPORTANCE_LOW
+ )
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ companion object {
+ private const val TAG = "TrackingService"
+ private const val CHANNEL_ID = "follow_me_tracking"
+ private const val NOTIFICATION_ID = 7001
+
+ fun start(context: Context) {
+ val intent = Intent(context, TrackingService::class.java)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, TrackingService::class.java))
+ }
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644 b/app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
new file mode 100644
index 0000000..2f6253b
--- /dev/null
+++ b/app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
@@ -0,0 +1,108 @@
+package top.outsidethebox.followme.ui
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat
+import top.outsidethebox.followme.data.AppPrefs
+import top.outsidethebox.followme.databinding.ActivityMainBinding
+import top.outsidethebox.followme.service.TrackingService
+
+class MainActivity : AppCompatActivity() {
+ private lateinit var binding: ActivityMainBinding
+ private lateinit var prefs: AppPrefs
+
+ private val permissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()
+ ) { refreshUi() }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ binding = ActivityMainBinding.inflate(layoutInflater)
+ setContentView(binding.root)
+ prefs = AppPrefs(applicationContext)
+
+ binding.endpointView.text = buildString {
+ append("Primary: ")
+ append(AppPrefs.HTTPS_ENDPOINT)
+ append("\nFallback: ")
+ append(AppPrefs.HTTP_ENDPOINT)
+ }
+
+ populateFields()
+ wireActions()
+ refreshUi()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ refreshUi()
+ }
+
+ private fun populateFields() {
+ binding.tokenInput.setText(prefs.getToken())
+ binding.roleInput.setText(prefs.getRole())
+ binding.networkInput.setText(prefs.getNetworkNote())
+ binding.sourceInput.setText(prefs.getSource())
+ }
+
+ private fun wireActions() {
+ binding.saveButton.setOnClickListener {
+ saveSettings()
+ Toast.makeText(this, "Saved", Toast.LENGTH_SHORT).show()
+ refreshUi()
+ }
+
+ binding.startButton.setOnClickListener {
+ saveSettings()
+ if (!hasBaseLocationPermission()) {
+ requestPermissions()
+ return@setOnClickListener
+ }
+ TrackingService.start(this)
+ prefs.setTrackingEnabled(true)
+ refreshUi()
+ }
+
+ binding.stopButton.setOnClickListener {
+ TrackingService.stop(this)
+ prefs.setTrackingEnabled(false)
+ refreshUi()
+ }
+ }
+
+ private fun saveSettings() {
+ prefs.setToken(binding.tokenInput.text?.toString()?.trim().orEmpty())
+ prefs.setRole(binding.roleInput.text?.toString()?.trim().orEmpty())
+ prefs.setNetworkNote(binding.networkInput.text?.toString()?.trim().orEmpty())
+ prefs.setSource(binding.sourceInput.text?.toString()?.trim().ifBlank { AppPrefs.DEFAULT_SOURCE })
+ }
+
+ private fun refreshUi() {
+ val protocol = prefs.getPreferredTransport().uppercase()
+ binding.protocolView.text = "Preferred protocol: $protocol (HTTPS fallback count: ${prefs.getConsecutiveHttpsFallbacks()})"
+ binding.lastUploadView.text = "Last upload: ${prefs.getLastUpload()}"
+ binding.statusView.text = if (prefs.isTrackingEnabled()) "Status: running" else "Status: stopped"
+ }
+
+ private fun hasBaseLocationPermission(): Boolean {
+ val fine = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ val coarse = ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+ return fine || coarse
+ }
+
+ private fun requestPermissions() {
+ val permissions = mutableListOf(
+ Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION
+ )
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ permissions += Manifest.permission.ACCESS_BACKGROUND_LOCATION
+ }
+ permissionLauncher.launch(permissions.toTypedArray())
+ }
+}
diff --git a/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt b/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
index 231fa0e..e01ddfb 100644
--- a/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
+++ b/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
@@ -1,16 +1,29 @@
package top.outsidethebox.followme.util
-import java.time.Instant
-import java.time.ZoneId
-import java.time.format.DateTimeFormatter
+import java.text.SimpleDateFormat
+import java.util.Date
import java.util.Locale
+import java.util.TimeZone
object TimeUtils {
- private val localFormatter: DateTimeFormatter =
- DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a z", Locale.US)
- .withZone(ZoneId.systemDefault())
+ private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
- fun nowIsoUtc(): String = Instant.now().toString()
+ private val prettyLocalFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
- fun nowPrettyLocal(): String = localFormatter.format(Instant.now())
+ @Synchronized
+ fun nowIsoUtc(): String {
+ return isoFormatter.format(Date())
+ }
+
+ @Synchronized
+ fun millisToIsoUtc(millis: Long): String {
+ return isoFormatter.format(Date(millis))
+ }
+
+ @Synchronized
+ fun nowPrettyLocal(): String {
+ return prettyLocalFormatter.format(Date())
+ }
}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.png b/app/src/main/res/drawable/ic_launcher_foreground.png
new file mode 100644
index 0000000..2860387
Binary files /dev/null and b/app/src/main/res/drawable/ic_launcher_foreground.png differ
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
deleted file mode 100644
index 37e5bdb..0000000
--- a/app/src/main/res/drawable/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/app/src/main/res/drawable/otb_logo.png b/app/src/main/res/drawable/otb_logo.png
new file mode 100644
index 0000000..dd9f3fa
Binary files /dev/null and b/app/src/main/res/drawable/otb_logo.png differ
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index 23619fc..2ed98ab 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -10,18 +10,34 @@
android:orientation="vertical"
android:padding="16dp">
-
+ android:orientation="horizontal"
+ android:gravity="center_vertical">
+
+
+
+
+
- otb-tcom
+ follow-me
Tracking
otb-tcom tracking active
Posting location updates to follow-me
diff --git a/backups/follow-me-v0.4.0.zip b/backups/follow-me-v0.4.0.zip
new file mode 100644
index 0000000..fe2d25e
Binary files /dev/null and b/backups/follow-me-v0.4.0.zip differ