commit ea535d89b7b238fdf1750d0a1f6b9e4405fadcf5 Author: def670 Date: Mon Mar 16 16:37:48 2026 -0400 Initial Android tracker scaffold for follow-me diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..615cf40 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle/ +/local.properties +/.idea/ +.DS_Store +/build/ +/captures/ +.externalNativeBuild/ +.cxx/ +app/build/ diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md new file mode 100644 index 0000000..be3a009 --- /dev/null +++ b/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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aeeb446 --- /dev/null +++ b/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: ` +- `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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..17c9064 --- /dev/null +++ b/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") +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..a1e5cc2 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# No custom rules yet. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..21ca131 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt b/app/src/main/java/top/outsidethebox/followme/data/AppPrefs.kt new file mode 100644 index 0000000..1b9dc72 --- /dev/null +++ b/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" + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt b/app/src/main/java/top/outsidethebox/followme/data/GpsPayload.kt new file mode 100644 index 0000000..b91dad8 --- /dev/null +++ b/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() + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt b/app/src/main/java/top/outsidethebox/followme/data/IngestRepository.kt new file mode 100644 index 0000000..d6e3b7e --- /dev/null +++ b/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) + } + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt b/app/src/main/java/top/outsidethebox/followme/location/TrackingMode.kt new file mode 100644 index 0000000..a9571c9 --- /dev/null +++ b/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) +} diff --git a/app/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt b/app/src/main/java/top/outsidethebox/followme/service/BootReceiver.kt new file mode 100644 index 0000000..98d0a3f --- /dev/null +++ b/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) + } + } + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt b/app/src/main/java/top/outsidethebox/followme/service/TrackingService.kt new file mode 100644 index 0000000..9e95e45 --- /dev/null +++ b/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)) + } + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt b/app/src/main/java/top/outsidethebox/followme/ui/MainActivity.kt new file mode 100644 index 0000000..2f6253b --- /dev/null +++ b/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()) + } +} diff --git a/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt b/app/src/main/java/top/outsidethebox/followme/util/TimeUtils.kt new file mode 100644 index 0000000..231fa0e --- /dev/null +++ b/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()) +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..37e5bdb --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..23619fc --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,144 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..ab35b9f --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + #455A64 + #263238 + #80CBC4 + #FFFFFF + #000000 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..226839a --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + otb-tcom + Tracking + otb-tcom tracking active + Posting location updates to follow-me + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..da57f2d --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..e1e81fa --- /dev/null +++ b/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 +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..b30f571 --- /dev/null +++ b/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")