Browse Source

follow-me-android v0.5.0 initial import

master
def 2 weeks ago
parent
commit
f096acdd7c
  1. 36
      .gitignore
  2. 66
      PROJECT_STATE.md
  3. 32
      README.md
  4. 61
      app.bak.20260330-214820/build.gradle.kts
  5. 1
      app.bak.20260330-214820/proguard-rules.pro
  6. 47
      app.bak.20260330-214820/src/main/AndroidManifest.xml
  7. 52
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
  8. 27
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
  9. 99
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
  10. 6
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
  11. 18
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
  12. 298
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
  13. 206
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
  14. 206
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
  15. 206
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
  16. 206
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
  17. 108
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
  18. 108
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
  19. 29
      app.bak.20260330-214820/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
  20. BIN
      app.bak.20260330-214820/src/main/res/drawable/ic_launcher_foreground.png
  21. BIN
      app.bak.20260330-214820/src/main/res/drawable/otb_logo.png
  22. 160
      app.bak.20260330-214820/src/main/res/layout/activity_main.xml
  23. BIN
      app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher.png
  24. BIN
      app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher_round.png
  25. BIN
      app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher.png
  26. BIN
      app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher_round.png
  27. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher.png
  28. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  29. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher.png
  30. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  31. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  32. BIN
      app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  33. 7
      app.bak.20260330-214820/src/main/res/values/colors.xml
  34. 6
      app.bak.20260330-214820/src/main/res/values/strings.xml
  35. 9
      app.bak.20260330-214820/src/main/res/values/themes.xml
  36. 61
      app.bak.identity.20260330-222009/build.gradle.kts
  37. 1
      app.bak.identity.20260330-222009/proguard-rules.pro
  38. 47
      app.bak.identity.20260330-222009/src/main/AndroidManifest.xml
  39. 52
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
  40. 27
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
  41. 99
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
  42. 6
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
  43. 18
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
  44. 298
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
  45. 206
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
  46. 206
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
  47. 206
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
  48. 206
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
  49. 108
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
  50. 108
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
  51. 29
      app.bak.identity.20260330-222009/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
  52. BIN
      app.bak.identity.20260330-222009/src/main/res/drawable/ic_launcher_foreground.png
  53. BIN
      app.bak.identity.20260330-222009/src/main/res/drawable/otb_logo.png
  54. 160
      app.bak.identity.20260330-222009/src/main/res/layout/activity_main.xml
  55. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher.png
  56. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher_round.png
  57. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher.png
  58. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher_round.png
  59. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher.png
  60. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  61. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher.png
  62. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  63. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  64. BIN
      app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  65. 7
      app.bak.identity.20260330-222009/src/main/res/values/colors.xml
  66. 6
      app.bak.identity.20260330-222009/src/main/res/values/strings.xml
  67. 9
      app.bak.identity.20260330-222009/src/main/res/values/themes.xml
  68. 10
      app/build.gradle.kts
  69. 61
      app/build.gradle.kts.bak.fix.20260330-222351
  70. 7
      app/src/main/AndroidManifest.xml
  71. 39
      app/src/main/java/top/outsidethebox/followme/DeviceIdentity.kt
  72. 10
      app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
  73. 34
      app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt.bak.fix.20260330-222351
  74. 136
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
  75. 206
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-162412
  76. 206
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-164620
  77. 206
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171557
  78. 206
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.20260318-171616
  79. 299
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt.bak.fix.20260330-222351
  80. 108
      app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt.bak.20260316-205644
  81. 29
      app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
  82. BIN
      app/src/main/res/drawable/ic_launcher_foreground.png
  83. 12
      app/src/main/res/drawable/ic_launcher_foreground.xml
  84. BIN
      app/src/main/res/drawable/otb_logo.png
  85. 26
      app/src/main/res/layout/activity_main.xml
  86. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher.png
  87. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher_round.png
  88. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher.png
  89. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher_round.png
  90. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher.png
  91. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
  92. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher.png
  93. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
  94. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
  95. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
  96. 2
      app/src/main/res/values/strings.xml
  97. BIN
      backups/follow-me-v0.4.0.zip

36
.gitignore vendored

@ -1,12 +1,32 @@
*.iml # Gradle
.gradle/ .gradle/
/local.properties build/
/.idea/ */build/
# Android Studio
.idea/
*.iml
# Local config
local.properties
# Logs
*.log
# OS
.DS_Store .DS_Store
/build/
/captures/ # APK outputs
*.apk
# Keystore (VERY IMPORTANT)
*.jks
*.keystore
# Secrets
secrets.properties
.env
# NDK
.externalNativeBuild/ .externalNativeBuild/
.cxx/ .cxx/
app/build/
local.properties

66
PROJECT_STATE.md

@ -1,50 +1,16 @@
# PROJECT_STATE.md # follow-me-android Project State
## Project ## Current State
- Name: follow-me - Existing working build confirmed
- App label: otb-tcom - Used for live GPS tracking
- Version: v0.1.0 - Integrated with otb-tracker backend
- Build date: 2026-03-16
- Platform: Android ## Next Steps
- Package: top.outsidethebox.followme - Align with follow-me-v2 API
- Minimum Android version: 6.0 (API 23) - Add token-based authentication
- Improve retry + offline queue
## Current scope - Add configurable tracking modes
Working Android client scaffold for GPS tracking into OTB Tracker.
## Notes
## Current implemented features - Must remain lightweight for older devices
- Foreground tracking service - Stability > features
- 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

32
README.md

@ -1,3 +1,31 @@
# follow-me # follow-me-android
android app for https://otb-tracker.outsidethebox.top tracking 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

61
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")
}

1
app.bak.20260330-214820/proguard-rules.pro vendored

@ -0,0 +1 @@
# No custom rules yet.

47
app.bak.20260330-214820/src/main/AndroidManifest.xml

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="follow-me"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FollowMe"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.TrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

52
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"
}
}

27
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()
}
}

99
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)
}
}
}

6
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)
}

18
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)
}
}
}
}

298
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

108
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())
}
}

108
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())
}
}

29
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())
}
}

BIN
app.bak.20260330-214820/src/main/res/drawable/ic_launcher_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
app.bak.20260330-214820/src/main/res/drawable/otb_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

160
app.bak.20260330-214820/src/main/res/layout/activity_main.xml

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/headerLogo"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="10dp"
android:adjustViewBounds="true"
android:contentDescription="OTB logo"
android:src="@drawable/otb_logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="follow-me"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Operational app name: follow-me"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Device token (X-API-Key)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Install role (freeadmin or drone)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/roleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="drone" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Network name / code (local note)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/networkInput"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Source label">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/sourceInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="otb-tcom" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/endpointView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Endpoints"
android:textSize="14sp" />
<TextView
android:id="@+id/statusView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Status: stopped"
android:textStyle="bold" />
<TextView
android:id="@+id/lastUploadView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Last upload: never" />
<TextView
android:id="@+id/protocolView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Preferred protocol: HTTPS" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Save" />
<Space
android:layout_width="12dp"
android:layout_height="1dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Stop" />
</LinearLayout>
</ScrollView>

BIN
app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-hdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-mdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
app.bak.20260330-214820/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

7
app.bak.20260330-214820/src/main/res/values/colors.xml

@ -0,0 +1,7 @@
<resources>
<color name="purple_500">#455A64</color>
<color name="purple_700">#263238</color>
<color name="teal_200">#80CBC4</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>

6
app.bak.20260330-214820/src/main/res/values/strings.xml

@ -0,0 +1,6 @@
<resources>
<string name="app_name">follow-me</string>
<string name="notification_channel_name">Tracking</string>
<string name="notification_title">otb-tcom tracking active</string>
<string name="notification_text">Posting location updates to follow-me</string>
</resources>

9
app.bak.20260330-214820/src/main/res/values/themes.xml

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.FollowMe" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/purple_700</item>
</style>
</resources>

61
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")
}

1
app.bak.identity.20260330-222009/proguard-rules.pro vendored

@ -0,0 +1 @@
# No custom rules yet.

47
app.bak.identity.20260330-222009/src/main/AndroidManifest.xml

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="follow-me"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.FollowMe"
android:usesCleartextTraffic="true">
<activity
android:name=".ui.MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".service.TrackingService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="location" />
<receiver
android:name=".service.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
</application>
</manifest>

52
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"
}
}

27
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()
}
}

99
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)
}
}
}

6
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)
}

18
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)
}
}
}
}

298
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

108
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())
}
}

108
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())
}
}

29
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())
}
}

BIN
app.bak.identity.20260330-222009/src/main/res/drawable/ic_launcher_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/drawable/otb_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

160
app.bak.identity.20260330-222009/src/main/res/layout/activity_main.xml

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageView
android:id="@+id/headerLogo"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="10dp"
android:adjustViewBounds="true"
android:contentDescription="OTB logo"
android:src="@drawable/otb_logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="follow-me"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="Operational app name: follow-me"
android:textSize="14sp" />
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:hint="Device token (X-API-Key)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/tokenInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textNoSuggestions" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Install role (freeadmin or drone)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/roleInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="drone" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Network name / code (local note)">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/networkInput"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="Source label">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/sourceInput"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="otb-tcom" />
</com.google.android.material.textfield.TextInputLayout>
<TextView
android:id="@+id/endpointView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Endpoints"
android:textSize="14sp" />
<TextView
android:id="@+id/statusView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="Status: stopped"
android:textStyle="bold" />
<TextView
android:id="@+id/lastUploadView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Last upload: never" />
<TextView
android:id="@+id/protocolView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="Preferred protocol: HTTPS" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center"
android:orientation="horizontal">
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Save" />
<Space
android:layout_width="12dp"
android:layout_height="1dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
style="@style/Widget.MaterialComponents.Button"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Start" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/stopButton"
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Stop" />
</LinearLayout>
</ScrollView>

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-hdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-mdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

BIN
app.bak.identity.20260330-222009/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

7
app.bak.identity.20260330-222009/src/main/res/values/colors.xml

@ -0,0 +1,7 @@
<resources>
<color name="purple_500">#455A64</color>
<color name="purple_700">#263238</color>
<color name="teal_200">#80CBC4</color>
<color name="white">#FFFFFF</color>
<color name="black">#000000</color>
</resources>

6
app.bak.identity.20260330-222009/src/main/res/values/strings.xml

@ -0,0 +1,6 @@
<resources>
<string name="app_name">follow-me</string>
<string name="notification_channel_name">Tracking</string>
<string name="notification_title">otb-tcom tracking active</string>
<string name="notification_text">Posting location updates to follow-me</string>
</resources>

9
app.bak.identity.20260330-222009/src/main/res/values/themes.xml

@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.FollowMe" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<item name="colorSecondary">@color/teal_200</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/purple_700</item>
</style>
</resources>

10
app/build.gradle.kts

@ -11,8 +11,8 @@ android {
applicationId = "top.outsidethebox.followme" applicationId = "top.outsidethebox.followme"
minSdk = 23 minSdk = 23
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 5
versionName = "0.1.0" versionName = "0.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@ -25,11 +25,15 @@ android {
"proguard-rules.pro" "proguard-rules.pro"
) )
} }
debug {
isMinifyEnabled = false
}
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
isCoreLibraryDesugaringEnabled = true
} }
kotlinOptions { kotlinOptions {
@ -42,6 +46,8 @@ android {
} }
dependencies { dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.7.0") implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")

61
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")
}

7
app/src/main/AndroidManifest.xml

@ -5,16 +5,15 @@
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<application <application
android:allowBackup="true" android:allowBackup="true"
android:icon="@drawable/ic_launcher_foreground" android:icon="@mipmap/ic_launcher"
android:label="otb-tcom" android:label="follow-me"
android:roundIcon="@drawable/ic_launcher_foreground" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.FollowMe" android:theme="@style/Theme.FollowMe"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">

39
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<String, String, String> {
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)
}
}

10
app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt

@ -1,8 +1,11 @@
package top.outsidethebox.followme.data package top.outsidethebox.followme.data
import android.content.Context
import org.json.JSONObject import org.json.JSONObject
import top.outsidethebox.followme.DeviceIdentity
data class GpsPayload( data class GpsPayload(
val context: Context,
val latitude: Double, val latitude: Double,
val longitude: Double, val longitude: Double,
val speedKmh: Double? = null, val speedKmh: Double? = null,
@ -14,14 +17,21 @@ data class GpsPayload(
) { ) {
fun toJson(): String { fun toJson(): String {
val json = JSONObject() val json = JSONObject()
val (deviceUuid, androidId, deviceName) = DeviceIdentity.get(context)
json.put("latitude", latitude) json.put("latitude", latitude)
json.put("longitude", longitude) 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) } speedKmh?.let { json.put("speed_kmh", it) }
accuracyM?.let { json.put("accuracy_m", it) } accuracyM?.let { json.put("accuracy_m", it) }
headingDeg?.let { json.put("heading_deg", it) } headingDeg?.let { json.put("heading_deg", it) }
altitudeM?.let { json.put("altitude_m", it) } altitudeM?.let { json.put("altitude_m", it) }
recordedAt?.let { json.put("recorded_at", it) } recordedAt?.let { json.put("recorded_at", it) }
json.put("source", source) json.put("source", source)
return json.toString() return json.toString()
} }
} }

34
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()
}
}

136
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.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.location.Location import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.IBinder import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat 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.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationRequest
@ -42,12 +48,15 @@ class TrackingService : Service() {
private var currentMode: TrackingMode = TrackingMode.MOVING private var currentMode: TrackingMode = TrackingMode.MOVING
private var locationCallback: LocationCallback? = null private var locationCallback: LocationCallback? = null
private var locationManager: LocationManager? = null
private var gpsLocationListener: LocationListener? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
prefs = AppPrefs(applicationContext) prefs = AppPrefs(applicationContext)
repository = IngestRepository(applicationContext) repository = IngestRepository(applicationContext)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
locationManager = getSystemService(LOCATION_SERVICE) as LocationManager
createNotificationChannel() createNotificationChannel()
} }
@ -77,7 +86,7 @@ class TrackingService : Service() {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_title)) .setContentTitle(getString(R.string.notification_title))
.setContentText(getString(R.string.notification_text)) .setContentText(getString(R.string.notification_text))
.setSmallIcon(android.R.drawable.ic_menu_mylocation) .setSmallIcon(R.mipmap.ic_launcher)
.setContentIntent(pendingIntent) .setContentIntent(pendingIntent)
.setOngoing(true) .setOngoing(true)
.build() .build()
@ -86,7 +95,11 @@ class TrackingService : Service() {
this, this,
NOTIFICATION_ID, NOTIFICATION_ID,
notification, 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() stopLocationUpdates()
currentMode = mode currentMode = mode
val request = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, mode.intervalMs) val playServicesOk =
.setMinUpdateIntervalMillis(mode.intervalMs) GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
.setWaitForAccurateLocation(false)
.build() 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() { gpsLocationListener = object : LocationListener {
override fun onLocationResult(result: LocationResult) { override fun onLocationChanged(location: Location) {
val location = result.lastLocation ?: return
val nextMode = decideMode(location) val nextMode = decideMode(location)
if (nextMode != currentMode) { if (nextMode != currentMode) {
startLocationUpdates(nextMode) startLocationUpdates(nextMode)
return
} }
uploadLocation(location) 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() { private fun stopLocationUpdates() {
locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) } try {
locationCallback?.let { fusedLocationClient.removeLocationUpdates(it) }
} catch (_: Exception) {
}
locationCallback = null locationCallback = null
try {
gpsLocationListener?.let { listener ->
locationManager?.removeUpdates(listener)
}
} catch (_: Exception) {
}
gpsLocationListener = null
} }
private fun decideMode(location: Location): TrackingMode { private fun decideMode(location: Location): TrackingMode {
@ -128,13 +238,14 @@ class TrackingService : Service() {
private fun uploadLocation(location: Location) { private fun uploadLocation(location: Location) {
val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0 val speedKmh = if (location.hasSpeed()) (location.speed * 3.6) else 0.0
val payload = GpsPayload( val payload = GpsPayload(
context = this,
latitude = location.latitude, latitude = location.latitude,
longitude = location.longitude, longitude = location.longitude,
speedKmh = speedKmh, speedKmh = speedKmh,
accuracyM = if (location.hasAccuracy()) location.accuracy else null, accuracyM = if (location.hasAccuracy()) location.accuracy else null,
headingDeg = if (location.hasBearing()) location.bearing else null, headingDeg = if (location.hasBearing()) location.bearing else null,
altitudeM = if (location.hasAltitude()) location.altitude 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 } source = prefs.getSource().ifBlank { AppPrefs.DEFAULT_SOURCE }
) )
@ -168,6 +279,7 @@ class TrackingService : Service() {
} }
companion object { companion object {
private const val TAG = "TrackingService"
private const val CHANNEL_ID = "follow_me_tracking" private const val CHANNEL_ID = "follow_me_tracking"
private const val NOTIFICATION_ID = 7001 private const val NOTIFICATION_ID = 7001

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

206
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))
}
}
}

299
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))
}
}
}

108
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())
}
}

29
app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt

@ -1,16 +1,29 @@
package top.outsidethebox.followme.util package top.outsidethebox.followme.util
import java.time.Instant import java.text.SimpleDateFormat
import java.time.ZoneId import java.util.Date
import java.time.format.DateTimeFormatter
import java.util.Locale import java.util.Locale
import java.util.TimeZone
object TimeUtils { object TimeUtils {
private val localFormatter: DateTimeFormatter = private val isoFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply {
DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a z", Locale.US) timeZone = TimeZone.getTimeZone("UTC")
.withZone(ZoneId.systemDefault()) }
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())
}
} }

BIN
app/src/main/res/drawable/ic_launcher_foreground.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

12
app/src/main/res/drawable/ic_launcher_foreground.xml

@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#263238"
android:pathData="M18,18h72v72h-72z" />
<path
android:fillColor="#80CBC4"
android:pathData="M54,22c-12.7,0 -23,10.3 -23,23c0,16.5 23,41 23,41s23,-24.5 23,-41c0,-12.7 -10.3,-23 -23,-23zM54,56c-6.1,0 -11,-4.9 -11,-11s4.9,-11 11,-11s11,4.9 11,11s-4.9,11 -11,11z" />
</vector>

BIN
app/src/main/res/drawable/otb_logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

26
app/src/main/res/layout/activity_main.xml

@ -10,18 +10,34 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<TextView <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="follow-me installer" android:orientation="horizontal"
android:textSize="24sp" android:gravity="center_vertical">
android:textStyle="bold" />
<ImageView
android:id="@+id/headerLogo"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="10dp"
android:adjustViewBounds="true"
android:contentDescription="OTB logo"
android:src="@drawable/otb_logo" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="follow-me"
android:textSize="24sp"
android:textStyle="bold" />
</LinearLayout>
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="6dp" android:layout_marginTop="6dp"
android:text="Operational app name: otb-tcom" android:text="Operational app name: follow-me"
android:textSize="14sp" /> android:textSize="14sp" />
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout

BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

2
app/src/main/res/values/strings.xml

@ -1,5 +1,5 @@
<resources> <resources>
<string name="app_name">otb-tcom</string> <string name="app_name">follow-me</string>
<string name="notification_channel_name">Tracking</string> <string name="notification_channel_name">Tracking</string>
<string name="notification_title">otb-tcom tracking active</string> <string name="notification_title">otb-tcom tracking active</string>
<string name="notification_text">Posting location updates to follow-me</string> <string name="notification_text">Posting location updates to follow-me</string>

BIN
backups/follow-me-v0.4.0.zip

Binary file not shown.
Loading…
Cancel
Save