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