Compare commits
No commits in common. '16b4593463cd8506a319106ee31e4af6f5e508f1' and '364b72ad7ce969fc6444b92f7160f500396ac44f' have entirely different histories.
16b4593463
...
364b72ad7c
21 changed files with 0 additions and 881 deletions
@ -1,10 +0,0 @@ |
|||||||
*.iml |
|
||||||
.gradle/ |
|
||||||
/local.properties |
|
||||||
/.idea/ |
|
||||||
.DS_Store |
|
||||||
/build/ |
|
||||||
/captures/ |
|
||||||
.externalNativeBuild/ |
|
||||||
.cxx/ |
|
||||||
app/build/ |
|
||||||
@ -1,50 +0,0 @@ |
|||||||
# 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 |
|
||||||
@ -1,55 +0,0 @@ |
|||||||
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 = 1 |
|
||||||
versionName = "0.1.0" |
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" |
|
||||||
} |
|
||||||
|
|
||||||
buildTypes { |
|
||||||
release { |
|
||||||
isMinifyEnabled = false |
|
||||||
proguardFiles( |
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"), |
|
||||||
"proguard-rules.pro" |
|
||||||
) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
compileOptions { |
|
||||||
sourceCompatibility = JavaVersion.VERSION_17 |
|
||||||
targetCompatibility = JavaVersion.VERSION_17 |
|
||||||
} |
|
||||||
|
|
||||||
kotlinOptions { |
|
||||||
jvmTarget = "17" |
|
||||||
} |
|
||||||
|
|
||||||
buildFeatures { |
|
||||||
viewBinding = true |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
dependencies { |
|
||||||
implementation("androidx.core:core-ktx:1.12.0") |
|
||||||
implementation("androidx.appcompat:appcompat:1.7.0") |
|
||||||
implementation("com.google.android.material:material:1.11.0") |
|
||||||
implementation("androidx.activity:activity-ktx:1.8.2") |
|
||||||
implementation("androidx.constraintlayout:constraintlayout:2.1.4") |
|
||||||
implementation("androidx.lifecycle:lifecycle-service:2.7.0") |
|
||||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") |
|
||||||
implementation("com.google.android.gms:play-services-location:21.2.0") |
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.12.0") |
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") |
|
||||||
} |
|
||||||
@ -1,48 +0,0 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> |
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" /> |
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> |
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" /> |
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> |
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" /> |
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> |
|
||||||
<uses-permission android:name="android.permission.WAKE_LOCK" /> |
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> |
|
||||||
|
|
||||||
<application |
|
||||||
android:allowBackup="true" |
|
||||||
android:icon="@drawable/ic_launcher_foreground" |
|
||||||
android:label="otb-tcom" |
|
||||||
android:roundIcon="@drawable/ic_launcher_foreground" |
|
||||||
android:supportsRtl="true" |
|
||||||
android:theme="@style/Theme.FollowMe" |
|
||||||
android:usesCleartextTraffic="true"> |
|
||||||
|
|
||||||
<activity |
|
||||||
android:name=".ui.MainActivity" |
|
||||||
android:exported="true"> |
|
||||||
<intent-filter> |
|
||||||
<action android:name="android.intent.action.MAIN" /> |
|
||||||
<category android:name="android.intent.category.LAUNCHER" /> |
|
||||||
</intent-filter> |
|
||||||
</activity> |
|
||||||
|
|
||||||
<service |
|
||||||
android:name=".service.TrackingService" |
|
||||||
android:enabled="true" |
|
||||||
android:exported="false" |
|
||||||
android:foregroundServiceType="location" /> |
|
||||||
|
|
||||||
<receiver |
|
||||||
android:name=".service.BootReceiver" |
|
||||||
android:enabled="true" |
|
||||||
android:exported="true"> |
|
||||||
<intent-filter> |
|
||||||
<action android:name="android.intent.action.BOOT_COMPLETED" /> |
|
||||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" /> |
|
||||||
</intent-filter> |
|
||||||
</receiver> |
|
||||||
</application> |
|
||||||
|
|
||||||
</manifest> |
|
||||||
@ -1,52 +0,0 @@ |
|||||||
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" |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,27 +0,0 @@ |
|||||||
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() |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,99 +0,0 @@ |
|||||||
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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
package top.outsidethebox.followme.location |
|
||||||
|
|
||||||
enum class TrackingMode(val intervalMs: Long) { |
|
||||||
MOVING(10_000L), |
|
||||||
IDLE(300_000L) |
|
||||||
} |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,187 +0,0 @@ |
|||||||
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.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 locationCallback: LocationCallback? = null |
|
||||||
|
|
||||||
override fun onCreate() { |
|
||||||
super.onCreate() |
|
||||||
prefs = AppPrefs(applicationContext) |
|
||||||
repository = IngestRepository(applicationContext) |
|
||||||
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this) |
|
||||||
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, mode.intervalMs) |
|
||||||
.setMinUpdateIntervalMillis(mode.intervalMs) |
|
||||||
.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)) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,108 +0,0 @@ |
|||||||
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()) |
|
||||||
} |
|
||||||
} |
|
||||||
@ -1,16 +0,0 @@ |
|||||||
package top.outsidethebox.followme.util |
|
||||||
|
|
||||||
import java.time.Instant |
|
||||||
import java.time.ZoneId |
|
||||||
import java.time.format.DateTimeFormatter |
|
||||||
import java.util.Locale |
|
||||||
|
|
||||||
object TimeUtils { |
|
||||||
private val localFormatter: DateTimeFormatter = |
|
||||||
DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss a z", Locale.US) |
|
||||||
.withZone(ZoneId.systemDefault()) |
|
||||||
|
|
||||||
fun nowIsoUtc(): String = Instant.now().toString() |
|
||||||
|
|
||||||
fun nowPrettyLocal(): String = localFormatter.format(Instant.now()) |
|
||||||
} |
|
||||||
@ -1,12 +0,0 @@ |
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
|
||||||
android:width="108dp" |
|
||||||
android:height="108dp" |
|
||||||
android:viewportWidth="108" |
|
||||||
android:viewportHeight="108"> |
|
||||||
<path |
|
||||||
android:fillColor="#263238" |
|
||||||
android:pathData="M18,18h72v72h-72z" /> |
|
||||||
<path |
|
||||||
android:fillColor="#80CBC4" |
|
||||||
android:pathData="M54,22c-12.7,0 -23,10.3 -23,23c0,16.5 23,41 23,41s23,-24.5 23,-41c0,-12.7 -10.3,-23 -23,-23zM54,56c-6.1,0 -11,-4.9 -11,-11s4.9,-11 11,-11s11,4.9 11,11s-4.9,11 -11,11z" /> |
|
||||||
</vector> |
|
||||||
@ -1,144 +0,0 @@ |
|||||||
<?xml version="1.0" encoding="utf-8"?> |
|
||||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android" |
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="match_parent"> |
|
||||||
|
|
||||||
<LinearLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:orientation="vertical" |
|
||||||
android:padding="16dp"> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:text="follow-me installer" |
|
||||||
android:textSize="24sp" |
|
||||||
android:textStyle="bold" /> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="6dp" |
|
||||||
android:text="Operational app name: otb-tcom" |
|
||||||
android:textSize="14sp" /> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="16dp" |
|
||||||
android:hint="Device token (X-API-Key)"> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText |
|
||||||
android:id="@+id/tokenInput" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:inputType="textNoSuggestions" /> |
|
||||||
</com.google.android.material.textfield.TextInputLayout> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="12dp" |
|
||||||
android:hint="Install role (freeadmin or drone)"> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText |
|
||||||
android:id="@+id/roleInput" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:text="drone" /> |
|
||||||
</com.google.android.material.textfield.TextInputLayout> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="12dp" |
|
||||||
android:hint="Network name / code (local note)"> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText |
|
||||||
android:id="@+id/networkInput" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" /> |
|
||||||
</com.google.android.material.textfield.TextInputLayout> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="12dp" |
|
||||||
android:hint="Source label"> |
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText |
|
||||||
android:id="@+id/sourceInput" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:text="otb-tcom" /> |
|
||||||
</com.google.android.material.textfield.TextInputLayout> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:id="@+id/endpointView" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="14dp" |
|
||||||
android:text="Endpoints" |
|
||||||
android:textSize="14sp" /> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:id="@+id/statusView" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="14dp" |
|
||||||
android:text="Status: stopped" |
|
||||||
android:textStyle="bold" /> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:id="@+id/lastUploadView" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="8dp" |
|
||||||
android:text="Last upload: never" /> |
|
||||||
|
|
||||||
<TextView |
|
||||||
android:id="@+id/protocolView" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="8dp" |
|
||||||
android:text="Preferred protocol: HTTPS" /> |
|
||||||
|
|
||||||
<LinearLayout |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="16dp" |
|
||||||
android:gravity="center" |
|
||||||
android:orientation="horizontal"> |
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton |
|
||||||
android:id="@+id/saveButton" |
|
||||||
android:layout_width="0dp" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_weight="1" |
|
||||||
android:text="Save" /> |
|
||||||
|
|
||||||
<Space |
|
||||||
android:layout_width="12dp" |
|
||||||
android:layout_height="1dp" /> |
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton |
|
||||||
android:id="@+id/startButton" |
|
||||||
style="@style/Widget.MaterialComponents.Button" |
|
||||||
android:layout_width="0dp" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_weight="1" |
|
||||||
android:text="Start" /> |
|
||||||
</LinearLayout> |
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton |
|
||||||
android:id="@+id/stopButton" |
|
||||||
style="@style/Widget.MaterialComponents.Button.OutlinedButton" |
|
||||||
android:layout_width="match_parent" |
|
||||||
android:layout_height="wrap_content" |
|
||||||
android:layout_marginTop="12dp" |
|
||||||
android:text="Stop" /> |
|
||||||
|
|
||||||
</LinearLayout> |
|
||||||
</ScrollView> |
|
||||||
@ -1,7 +0,0 @@ |
|||||||
<resources> |
|
||||||
<color name="purple_500">#455A64</color> |
|
||||||
<color name="purple_700">#263238</color> |
|
||||||
<color name="teal_200">#80CBC4</color> |
|
||||||
<color name="white">#FFFFFF</color> |
|
||||||
<color name="black">#000000</color> |
|
||||||
</resources> |
|
||||||
@ -1,6 +0,0 @@ |
|||||||
<resources> |
|
||||||
<string name="app_name">otb-tcom</string> |
|
||||||
<string name="notification_channel_name">Tracking</string> |
|
||||||
<string name="notification_title">otb-tcom tracking active</string> |
|
||||||
<string name="notification_text">Posting location updates to follow-me</string> |
|
||||||
</resources> |
|
||||||
@ -1,9 +0,0 @@ |
|||||||
<resources xmlns:tools="http://schemas.android.com/tools"> |
|
||||||
<style name="Theme.FollowMe" parent="Theme.MaterialComponents.DayNight.NoActionBar"> |
|
||||||
<item name="colorPrimary">@color/purple_500</item> |
|
||||||
<item name="colorPrimaryVariant">@color/purple_700</item> |
|
||||||
<item name="colorOnPrimary">@color/white</item> |
|
||||||
<item name="colorSecondary">@color/teal_200</item> |
|
||||||
<item name="android:statusBarColor" tools:targetApi="l">@color/purple_700</item> |
|
||||||
</style> |
|
||||||
</resources> |
|
||||||
@ -1,4 +0,0 @@ |
|||||||
plugins { |
|
||||||
id("com.android.application") version "8.2.2" apply false |
|
||||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false |
|
||||||
} |
|
||||||
@ -1,4 +0,0 @@ |
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 |
|
||||||
android.useAndroidX=true |
|
||||||
kotlin.code.style=official |
|
||||||
android.nonTransitiveRClass=true |
|
||||||
@ -1,18 +0,0 @@ |
|||||||
pluginManagement { |
|
||||||
repositories { |
|
||||||
google() |
|
||||||
mavenCentral() |
|
||||||
gradlePluginPortal() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
dependencyResolutionManagement { |
|
||||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) |
|
||||||
repositories { |
|
||||||
google() |
|
||||||
mavenCentral() |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
rootProject.name = "follow-me" |
|
||||||
include(":app") |
|
||||||
Loading…
Reference in new issue