Browse Source

Initial Android tracker scaffold for follow-me

master
def670 5 days ago
commit
ea535d89b7
  1. 10
      .gitignore
  2. 50
      PROJECT_STATE.md
  3. 79
      README.md
  4. 55
      app/build.gradle.kts
  5. 1
      app/proguard-rules.pro
  6. 48
      app/src/main/AndroidManifest.xml
  7. 52
      app/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt
  8. 27
      app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt
  9. 99
      app/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt
  10. 6
      app/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt
  11. 18
      app/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt
  12. 187
      app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt
  13. 108
      app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt
  14. 16
      app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt
  15. 12
      app/src/main/res/drawable/ic_launcher_foreground.xml
  16. 144
      app/src/main/res/layout/activity_main.xml
  17. 7
      app/src/main/res/values/colors.xml
  18. 6
      app/src/main/res/values/strings.xml
  19. 9
      app/src/main/res/values/themes.xml
  20. 4
      build.gradle.kts
  21. 4
      gradle.properties
  22. 18
      settings.gradle.kts

10
.gitignore vendored

@ -0,0 +1,10 @@
*.iml
.gradle/
/local.properties
/.idea/
.DS_Store
/build/
/captures/
.externalNativeBuild/
.cxx/
app/build/

50
PROJECT_STATE.md

@ -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

79
README.md

@ -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.

55
app/build.gradle.kts

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

1
app/proguard-rules.pro vendored

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

48
app/src/main/AndroidManifest.xml

@ -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>

52
app/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt

@ -0,0 +1,52 @@
package top.outsidethebox.followme.data
import android.content.Context
import android.content.SharedPreferences
class AppPrefs(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)
fun getToken(): String = prefs.getString(KEY_TOKEN, "") ?: ""
fun setToken(value: String) = prefs.edit().putString(KEY_TOKEN, value).apply()
fun getRole(): String = prefs.getString(KEY_ROLE, "drone") ?: "drone"
fun setRole(value: String) = prefs.edit().putString(KEY_ROLE, value).apply()
fun getNetworkNote(): String = prefs.getString(KEY_NETWORK_NOTE, "") ?: ""
fun setNetworkNote(value: String) = prefs.edit().putString(KEY_NETWORK_NOTE, value).apply()
fun getSource(): String = prefs.getString(KEY_SOURCE, DEFAULT_SOURCE) ?: DEFAULT_SOURCE
fun setSource(value: String) = prefs.edit().putString(KEY_SOURCE, value).apply()
fun isTrackingEnabled(): Boolean = prefs.getBoolean(KEY_TRACKING_ENABLED, false)
fun setTrackingEnabled(value: Boolean) = prefs.edit().putBoolean(KEY_TRACKING_ENABLED, value).apply()
fun getConsecutiveHttpsFallbacks(): Int = prefs.getInt(KEY_HTTPS_FALLBACKS, 0)
fun setConsecutiveHttpsFallbacks(value: Int) = prefs.edit().putInt(KEY_HTTPS_FALLBACKS, value).apply()
fun getPreferredTransport(): String = prefs.getString(KEY_PREFERRED_TRANSPORT, TRANSPORT_HTTPS) ?: TRANSPORT_HTTPS
fun setPreferredTransport(value: String) = prefs.edit().putString(KEY_PREFERRED_TRANSPORT, value).apply()
fun getLastUpload(): String = prefs.getString(KEY_LAST_UPLOAD, "never") ?: "never"
fun setLastUpload(value: String) = prefs.edit().putString(KEY_LAST_UPLOAD, value).apply()
fun resetHttpsFallbackCounter() = setConsecutiveHttpsFallbacks(0)
companion object {
private const val PREF_NAME = "follow_me_prefs"
private const val KEY_TOKEN = "token"
private const val KEY_ROLE = "role"
private const val KEY_NETWORK_NOTE = "network_note"
private const val KEY_SOURCE = "source"
private const val KEY_TRACKING_ENABLED = "tracking_enabled"
private const val KEY_HTTPS_FALLBACKS = "https_fallbacks"
private const val KEY_PREFERRED_TRANSPORT = "preferred_transport"
private const val KEY_LAST_UPLOAD = "last_upload"
const val TRANSPORT_HTTPS = "https"
const val TRANSPORT_HTTP = "http"
const val DEFAULT_SOURCE = "otb-tcom"
const val HTTPS_ENDPOINT = "https://otb-tracker.outsidethebox.top/api/gps-ingest"
const val HTTP_ENDPOINT = "http://otb-tracker.outsidethebox.top/api/gps-ingest"
}
}

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

@ -0,0 +1,27 @@
package top.outsidethebox.followme.data
import org.json.JSONObject
data class GpsPayload(
val latitude: Double,
val longitude: Double,
val speedKmh: Double? = null,
val accuracyM: Float? = null,
val headingDeg: Float? = null,
val altitudeM: Double? = null,
val recordedAt: String? = null,
val source: String = AppPrefs.DEFAULT_SOURCE
) {
fun toJson(): String {
val json = JSONObject()
json.put("latitude", latitude)
json.put("longitude", longitude)
speedKmh?.let { json.put("speed_kmh", it) }
accuracyM?.let { json.put("accuracy_m", it) }
headingDeg?.let { json.put("heading_deg", it) }
altitudeM?.let { json.put("altitude_m", it) }
recordedAt?.let { json.put("recorded_at", it) }
json.put("source", source)
return json.toString()
}
}

99
app/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt

@ -0,0 +1,99 @@
package top.outsidethebox.followme.data
import android.content.Context
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.util.concurrent.TimeUnit
data class IngestResult(
val success: Boolean,
val usedTransport: String,
val responseBody: String? = null,
val errorMessage: String? = null
)
class IngestRepository(context: Context) {
private val prefs = AppPrefs(context.applicationContext)
private val jsonType = "application/json; charset=utf-8".toMediaType()
private val client: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
fun send(payload: GpsPayload): IngestResult {
val token = prefs.getToken().trim()
if (token.isEmpty()) {
return IngestResult(false, prefs.getPreferredTransport(), errorMessage = "Missing device token")
}
val preferred = prefs.getPreferredTransport()
return if (preferred == AppPrefs.TRANSPORT_HTTP) {
sendHttpOnly(token, payload)
} else {
sendHttpsThenFallback(token, payload)
}
}
private fun sendHttpsThenFallback(token: String, payload: GpsPayload): IngestResult {
val httpsResult = post(AppPrefs.HTTPS_ENDPOINT, token, payload)
if (httpsResult.success) {
prefs.resetHttpsFallbackCounter()
prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTPS)
return httpsResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTPS)
}
val httpResult = post(AppPrefs.HTTP_ENDPOINT, token, payload)
if (httpResult.success) {
val newCount = prefs.getConsecutiveHttpsFallbacks() + 1
prefs.setConsecutiveHttpsFallbacks(newCount)
if (newCount >= 3) {
prefs.setPreferredTransport(AppPrefs.TRANSPORT_HTTP)
}
return httpResult.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
}
return IngestResult(
success = false,
usedTransport = AppPrefs.TRANSPORT_HTTPS,
errorMessage = httpsResult.errorMessage ?: httpResult.errorMessage
)
}
private fun sendHttpOnly(token: String, payload: GpsPayload): IngestResult {
val result = post(AppPrefs.HTTP_ENDPOINT, token, payload)
return if (result.success) {
result.copy(usedTransport = AppPrefs.TRANSPORT_HTTP)
} else {
result
}
}
private fun post(url: String, token: String, payload: GpsPayload): IngestResult {
val body = payload.toJson().toRequestBody(jsonType)
val request = Request.Builder()
.url(url)
.post(body)
.header("X-API-Key", token)
.header("Content-Type", "application/json")
.build()
return try {
client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (response.isSuccessful) {
IngestResult(true, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text)
} else {
IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, text, "HTTP ${response.code}")
}
}
} catch (e: IOException) {
IngestResult(false, if (url.startsWith("https")) AppPrefs.TRANSPORT_HTTPS else AppPrefs.TRANSPORT_HTTP, errorMessage = e.message)
}
}
}

6
app/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt

@ -0,0 +1,6 @@
package top.outsidethebox.followme.location
enum class TrackingMode(val intervalMs: Long) {
MOVING(10_000L),
IDLE(300_000L)
}

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

187
app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt

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

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

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

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

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

@ -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>

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

@ -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>

7
app/src/main/res/values/colors.xml

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

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

@ -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>

9
app/src/main/res/values/themes.xml

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

4
build.gradle.kts

@ -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
}

4
gradle.properties

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

18
settings.gradle.kts

@ -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…
Cancel
Save