commit
ea535d89b7
22 changed files with 960 additions and 0 deletions
@ -0,0 +1,10 @@
|
||||
*.iml |
||||
.gradle/ |
||||
/local.properties |
||||
/.idea/ |
||||
.DS_Store |
||||
/build/ |
||||
/captures/ |
||||
.externalNativeBuild/ |
||||
.cxx/ |
||||
app/build/ |
||||
@ -0,0 +1,50 @@
|
||||
# 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 |
||||
@ -0,0 +1,79 @@
|
||||
# follow-me |
||||
|
||||
Android tracker client for the OutsideTheBox `follow-me` service. |
||||
|
||||
Operational app label: **otb-tcom** |
||||
Project name / repo name: **follow-me** |
||||
|
||||
## MVP v0.1.0 |
||||
|
||||
- Android 6.0+ (`minSdk 23`) |
||||
- Foreground location tracking service |
||||
- HTTPS primary ingest endpoint with HTTP fallback |
||||
- Auto-default to HTTP after 3 consecutive HTTPS fallback events on that device |
||||
- Token-based header auth via `X-API-Key` |
||||
- Moving mode: 10 second posts |
||||
- Idle mode: 5 minute posts |
||||
- Reboot restart if tracking was enabled before reboot |
||||
- Simple installer/settings UI |
||||
|
||||
## Ingest behavior |
||||
|
||||
Primary: |
||||
- `https://otb-tracker.outsidethebox.top/api/gps-ingest` |
||||
|
||||
Fallback: |
||||
- `http://otb-tracker.outsidethebox.top/api/gps-ingest` |
||||
|
||||
Headers: |
||||
- `X-API-Key: <device token>` |
||||
- `Content-Type: application/json` |
||||
|
||||
JSON body: |
||||
```json |
||||
{ |
||||
"latitude": 43.8971, |
||||
"longitude": -78.8658, |
||||
"speed_kmh": 0, |
||||
"accuracy_m": 12, |
||||
"heading_deg": 0, |
||||
"altitude_m": 95, |
||||
"recorded_at": "2026-03-16T14:32:10Z", |
||||
"source": "otb-tcom" |
||||
} |
||||
``` |
||||
|
||||
## Build on ripper |
||||
|
||||
```bash |
||||
cd /home/def/outsidethebox |
||||
git clone ssh://git@git.etica-stats.org:5252/def/follow-me.git /home/def/outsidethebox/follow-me |
||||
cd /home/def/outsidethebox/follow-me |
||||
``` |
||||
|
||||
Copy this project into that directory, then build with Android Studio or the Gradle wrapper after generating it from Android Studio once. |
||||
|
||||
## Git bootstrap on ripper |
||||
|
||||
```bash |
||||
mkdir -p /home/def/outsidethebox |
||||
cd /home/def/outsidethebox |
||||
[ -d follow-me/.git ] || git clone ssh://git@git.etica-stats.org:5252/def/follow-me.git follow-me |
||||
cd /home/def/outsidethebox/follow-me |
||||
git remote -v |
||||
``` |
||||
|
||||
If the remote repo is empty and you want to seed it from this scaffold: |
||||
|
||||
```bash |
||||
cd /home/def/outsidethebox/follow-me |
||||
git add . |
||||
git commit -m "Initial Android tracker scaffold for follow-me" |
||||
git push -u origin master |
||||
``` |
||||
|
||||
## Notes |
||||
|
||||
- The app label is intentionally discreet: `otb-tcom`. |
||||
- Network onboarding, admin creation, drone enrollment, and role-specific install flows should be added once corresponding backend APIs exist. |
||||
- On Android 8.0+, real-time background tracking requires a foreground service with a visible notification. |
||||
@ -0,0 +1,55 @@
|
||||
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") |
||||
} |
||||
@ -0,0 +1,48 @@
|
||||
<?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> |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -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() |
||||
} |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
|
||||
package top.outsidethebox.followme.location |
||||
|
||||
enum class TrackingMode(val intervalMs: Long) { |
||||
MOVING(10_000L), |
||||
IDLE(300_000L) |
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,187 @@
|
||||
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)) |
||||
} |
||||
} |
||||
} |
||||
@ -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()) |
||||
} |
||||
} |
||||
@ -0,0 +1,16 @@
|
||||
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()) |
||||
} |
||||
@ -0,0 +1,12 @@
|
||||
<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> |
||||
@ -0,0 +1,144 @@
|
||||
<?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> |
||||
@ -0,0 +1,7 @@
|
||||
<resources> |
||||
<color name="purple_500">#455A64</color> |
||||
<color name="purple_700">#263238</color> |
||||
<color name="teal_200">#80CBC4</color> |
||||
<color name="white">#FFFFFF</color> |
||||
<color name="black">#000000</color> |
||||
</resources> |
||||
@ -0,0 +1,6 @@
|
||||
<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> |
||||
@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools"> |
||||
<style name="Theme.FollowMe" parent="Theme.MaterialComponents.DayNight.NoActionBar"> |
||||
<item name="colorPrimary">@color/purple_500</item> |
||||
<item name="colorPrimaryVariant">@color/purple_700</item> |
||||
<item name="colorOnPrimary">@color/white</item> |
||||
<item name="colorSecondary">@color/teal_200</item> |
||||
<item name="android:statusBarColor" tools:targetApi="l">@color/purple_700</item> |
||||
</style> |
||||
</resources> |
||||
@ -0,0 +1,4 @@
|
||||
plugins { |
||||
id("com.android.application") version "8.2.2" apply false |
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false |
||||
} |
||||
@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 |
||||
android.useAndroidX=true |
||||
kotlin.code.style=official |
||||
android.nonTransitiveRClass=true |
||||
@ -0,0 +1,18 @@
|
||||
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