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