1
0

Compare commits

..

13 Commits

Author SHA1 Message Date
Sweetbread 386fb20bff bump to 1.2 2025-05-23 12:14:12 +03:00
Sweetbread 2f4cfa33ad feat: add sentry 2025-05-23 12:12:16 +03:00
Sweetbread 5f7d4c0163 build: change apk name 2025-05-23 11:59:26 +03:00
Sweetbread 4e517b87cd feat: add statistics 2025-05-23 11:58:19 +03:00
Sweetbread 831b6bf491 build: increase min api up to 26 2025-05-23 11:54:42 +03:00
Sweetbread 515f950bde bump to 1.1 2025-05-23 11:28:06 +03:00
Sweetbread 761bd921ab feat: add stop tracking button 2025-05-23 11:28:06 +03:00
Sweetbread 7bee8910ec feat: add setting sending interval 2025-05-22 22:09:13 +03:00
Sweetbread d6f352be29 chore: change default URL 2025-05-22 00:12:25 +03:00
Sweetbread 52816c155b fix: change API 2025-05-21 23:55:40 +03:00
Sweetbread a3ff36ba67 impr: add notification permission check 2025-05-04 18:41:29 +03:00
Sweetbread 8a54db266f feat: add sending geocoords 2025-05-03 21:39:04 +03:00
Sweetbread db766c2acc feat: ask permissions 2025-05-03 17:21:10 +03:00
10 changed files with 446 additions and 23 deletions
+21 -3
View File
@@ -3,6 +3,8 @@
* Copyright (c) 2025. All rights reserved.
*/
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
@@ -15,12 +17,22 @@ android {
defaultConfig {
applicationId = "ru.risdeveau.geotracker"
minSdk = 24
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0"
versionCode = 3
versionName = "1.2"
setProperty("archivesBaseName", "$applicationId-v$versionCode($versionName)")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
val secretProperties = Properties().apply {
val secretFile = rootProject.file("secrets.properties")
if (secretFile.exists())
secretFile.inputStream().use { load(it) }
else
println("Warning: secrets.properties not found!")
}
manifestPlaceholders["sentry_url"] = secretProperties.getProperty("SENTRY_URL")!!
}
buildTypes {
@@ -30,6 +42,10 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
manifestPlaceholders["sentry_env"] = "production"
}
debug {
manifestPlaceholders["sentry_env"] = "develop"
}
}
compileOptions {
@@ -67,7 +83,9 @@ dependencies {
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.json)
implementation(libs.logback.classic)
implementation(libs.splitties.base)
implementation(libs.sentry)
}
+43
View File
@@ -6,7 +6,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<application
android:allowBackup="true"
@@ -17,7 +24,37 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.GeoTracker"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<meta-data
android:name="io.sentry.dsn"
android:value="${sentry_url}" />
<meta-data
android:name="io.sentry.environment"
android:value="${sentry_env}" />
<meta-data
android:name="io.sentry.send-default-pii"
android:value="true" />
<meta-data
android:name="io.sentry.traces.sample-rate"
android:value="1.0" />
<meta-data
android:name="io.sentry.traces.user-interaction.enable"
android:value="true" />
<meta-data
android:name="io.sentry.attach-screenshot"
android:value="true" />
<meta-data
android:name="io.sentry.attach-view-hierarchy"
android:value="true" />
<meta-data
android:name="io.sentry.session-replay.on-error-sample-rate"
android:value="1.0" />
<meta-data
android:name="io.sentry.session-replay.session-sample-rate"
android:value="0.1" />
<activity
android:name=".MainActivity"
android:exported="true"
@@ -29,6 +66,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".LocationForegroundService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
</application>
</manifest>
@@ -9,7 +9,12 @@ import io.ktor.client.*
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentType
import org.json.JSONObject
val client = HttpClient(OkHttp) {
install(Logging) {
@@ -38,6 +43,22 @@ suspend fun health(baseurl: String): Boolean {
/**
* Send data to a server
* @return true if sent successfully
*/
fun sendGeo(baseurl: String, data: GeoData) {
suspend fun sendGeo(baseurl: String = SettingsPreferences.url, data: GeoData): Boolean {
try {
val json = JSONObject()
json.put("lon", data.ln)
json.put("lat", data.lt)
json.put("user_name", data.nick)
client.post("$baseurl/api/app") {
contentType(ContentType.Application.Json)
setBody(json.toString(2))
}
return true
} catch (e: Exception) {
println("Error: ${e.message}")
return false
}
}
@@ -8,6 +8,13 @@ package ru.risdeveau.geotracker
import splitties.preferences.Preferences
object SettingsPreferences : Preferences("settings") {
var username by stringPref("username", "anonimous")
var url by stringPref("url", "https://example.com")
var username by stringPref("username", "")
var url by stringPref("url", "https://geo.tmp.codrs.ru")
val interval by IntPref("interval", 15)
}
object StatisticsPreferences : Preferences("statistics") {
var totalSent by IntPref("total_sent", 0)
var sessionSent by IntPref("session_sent", 0)
var lastSent by LongPref("last_sent", 0)
}
@@ -0,0 +1,122 @@
package ru.risdeveau.geotracker
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.Manifest.permission.POST_NOTIFICATIONS
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.time.Instant
class LocationForegroundService : Service() {
private lateinit var locationTracker: LocationTracker
private val serviceScope = CoroutineScope(Dispatchers.Main + Job())
override fun onCreate() {
Log.d("Service", "onCreate")
super.onCreate()
locationTracker = LocationTracker(this) { location ->
serviceScope.launch {
sendGeo(
data = GeoData(
lt = location.latitude,
ln = location.longitude,
nick = SettingsPreferences.username
)
)
}
StatisticsPreferences.apply {
totalSent++
sessionSent++
lastSent = Instant.now().epochSecond
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("Service", "onStartCommand")
if (
!(hasPermission(ACCESS_FINE_LOCATION)
&& (
(Build.VERSION.SDK_INT < 33)
|| hasPermission(POST_NOTIFICATIONS)
))
|| intent?.action == ACTION_STOP_SERVICE
) {
stopSelf()
return START_NOT_STICKY
}
createNotificationChannel()
val notification = createNotification()
startForeground(1, notification)
locationTracker.startTracking(SettingsPreferences.interval * 1000L)
return START_STICKY
}
override fun onDestroy() {
Log.d("Service", "Destroyed")
locationTracker.stopTracking()
super.onDestroy()
}
private fun createNotificationChannel() {
Log.d("Service", "createNotificationChannel")
val channel = NotificationChannel(
"location_channel",
"Отправка Местоположения",
NotificationManager.IMPORTANCE_HIGH
)
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
private fun createNotification(): Notification {
val stopIntent = Intent(this, LocationForegroundService::class.java).apply {
action = ACTION_STOP_SERVICE
}
val stopPendingIntent = PendingIntent.getService(
this,
0,
stopIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
else
PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, "location_channel")
.setContentTitle("Отслеживание местоположения")
.setContentText("Обновление каждые ${SettingsPreferences.interval} секунд")
.setSmallIcon(R.drawable.share_location)
.addAction(
R.drawable.cancel,
"Остановить",
stopPendingIntent
)
.setOngoing(true)
.build()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
const val ACTION_STOP_SERVICE = "ru.risdeveau.geotracker.STOP_SERVICE"
}
}
@@ -0,0 +1,62 @@
package ru.risdeveau.geotracker
import android.annotation.SuppressLint
import android.content.Context
import android.content.Context.LOCATION_SERVICE
import android.content.pm.PackageManager
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Bundle
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
class LocationTracker(
private val context: Context,
private val onLocationUpdate: (Location) -> Unit
) : LocationListener {
private val locationManager =
context.getSystemService(LOCATION_SERVICE) as LocationManager
@SuppressLint("MissingPermission")
fun startTracking(interval: Long = 5000, minDistance: Float = 0f) {
Log.d("Tracker", "perms: ${hasPermissions()}")
if (!hasPermissions()) return
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
interval,
minDistance,
this,
Looper.getMainLooper()
)
locationManager.getLastKnownLocation(LocationManager.GPS_PROVIDER)?.let {
onLocationUpdate(it)
}
}
fun stopTracking() {
locationManager.removeUpdates(this)
}
override fun onLocationChanged(location: Location) {
onLocationUpdate(location)
}
override fun onStatusChanged(provider: String?, status: Int, extras: Bundle?) {}
override fun onProviderEnabled(provider: String) {}
override fun onProviderDisabled(provider: String) {}
private fun hasPermissions(): Boolean {
return ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
}
@@ -5,18 +5,28 @@
package ru.risdeveau.geotracker
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.Manifest.permission.POST_NOTIFICATIONS
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
@@ -24,26 +34,29 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.compose.ui.text.input.KeyboardType
import androidx.core.content.ContextCompat
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.intellij.lang.annotations.JdkConstants
import ru.risdeveau.geotracker.ui.theme.GeoTrackerTheme
import splitties.resources.appColor
import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx
import splitties.resources.appStr
import kotlin.apply
import java.time.Instant
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
GeoTrackerTheme {
@@ -55,10 +68,36 @@ class MainActivity : ComponentActivity() {
var screen by remember { mutableStateOf<Screen>(Screen.Loading) }
when (screen) {
Screen.Main -> TODO()
Screen.Main -> {
var totalSent by remember { mutableIntStateOf(0) }
var sessionSent by remember { mutableIntStateOf(0) }
var lastSent by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
Log.d("Thread", "Starting...")
startLocationService()
StatisticsPreferences.sessionSent = 0
Log.d("Thread", "Started")
while (true) {
totalSent = StatisticsPreferences.totalSent
sessionSent = StatisticsPreferences.sessionSent
lastSent = (Instant.now().epochSecond - StatisticsPreferences.lastSent).toInt()
delay(1000)
}
}
Column(Modifier.align(Alignment.Center)) {
Text("Всего отправлено: $totalSent")
Text("Отправлено за эту сессию: $sessionSent")
Text("В последний раз было отпавлено $lastSent секунд назад")
}
}
Screen.Settings -> {
Settings(Modifier.align(Alignment.Center))
Settings(Modifier.align(Alignment.Center)) {
screen = Screen.Main
}
}
Screen.Loading -> {
@@ -68,7 +107,15 @@ class MainActivity : ComponentActivity() {
CircularProgressIndicator(Modifier.align(Alignment.Center))
LaunchedEffect(true) {
launch {
screen = if (health(SettingsPreferences.url))
screen = if (
health(SettingsPreferences.url)
&& SettingsPreferences.username.isNotBlank()
&& hasPermission(ACCESS_FINE_LOCATION)
&& (
(Build.VERSION.SDK_INT < 33)
|| hasPermission(POST_NOTIFICATIONS)
)
)
Screen.Main
else
Screen.Settings
@@ -90,12 +137,21 @@ sealed class Screen {
object Loading : Screen()
}
@OptIn(ExperimentalSplittiesApi::class)
@Composable
fun Settings(modifier: Modifier = Modifier) {
var username by remember { mutableStateOf("") }
var url by remember { mutableStateOf("") }
fun Settings(modifier: Modifier = Modifier, onConfirm: () -> Unit) {
var username by remember { mutableStateOf(SettingsPreferences.username) }
var url by remember { mutableStateOf(SettingsPreferences.url) }
var interval by remember { mutableIntStateOf(SettingsPreferences.interval) }
var urlIsValid by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) }
var fineLoc by remember { mutableStateOf(hasPermission(ACCESS_FINE_LOCATION)) }
var notifications by remember { mutableStateOf(
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(POST_NOTIFICATIONS)
} else true
) }
val hasPerms = fineLoc && notifications
LaunchedEffect(url) {
if (url.isNotEmpty()) {
@@ -116,13 +172,86 @@ fun Settings(modifier: Modifier = Modifier) {
OutlinedTextField(
value = url,
onValueChange = { url = it },
placeholder = { Text("https://geo.example.com", style = TextStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)) },
placeholder = {
Text("https://geo.example.com",
style = TextStyle(color = MaterialTheme.colorScheme.onSurfaceVariant)
)
},
label = { Text(appStr(R.string.server_url)) },
trailingIcon = { if (loading) CircularProgressIndicator() }
trailingIcon = {
if (loading) CircularProgressIndicator()
else if (urlIsValid) Icon(Icons.Outlined.Done, "Done")
}
)
Button({}, enabled = urlIsValid) {
OutlinedTextField(
value = interval.toString(),
onValueChange = {
val newVal = it.toIntOrNull()
if (newVal != null)
interval = newVal.coerceIn(1..300)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
label = { Text("Интервал отправки") }
)
GetPermission(ACCESS_FINE_LOCATION) { fineLoc = true; }
if (Build.VERSION.SDK_INT >= 33)
GetPermission(POST_NOTIFICATIONS) { notifications = true; }
Button({
SettingsPreferences.username = username.trim()
SettingsPreferences.url = url
onConfirm()
}, enabled = urlIsValid
&& !loading
&& username.trim().isNotEmpty()
&& hasPerms
) {
Text(appStr(R.string.apply))
}
}
}
@Composable
fun GetPermission(permission: String, onSuccess: () -> Unit) {
val context = LocalContext.current
var hasPerm by remember { mutableStateOf(
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
)}
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted -> hasPerm = granted }
Button(
onClick = { launcher.launch(permission) },
enabled = !hasPerm
) {
if (hasPerm) {
onSuccess()
Icon(Icons.Outlined.Done, "Done")
Text("Разрешение получено")
} else {
Text("Получить разрешение")
}
}
}
fun hasPermission(permission: String): Boolean {
return ContextCompat.checkSelfPermission(
appCtx,
permission
) == PackageManager.PERMISSION_GRANTED
}
fun startLocationService() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appCtx.startForegroundService(Intent(appCtx, LocationForegroundService::class.java))
Log.d("startLocationService", "startForegroundService")
} else {
appCtx.startService(Intent(appCtx, LocationForegroundService::class.java))
Log.d("startLocationService", "startService")
}
}
+9
View File
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M336,680L480,536L624,680L680,624L536,480L680,336L624,280L480,424L336,280L280,336L424,480L280,624L336,680ZM480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM480,800Q614,800 707,707Q800,614 800,480Q800,346 707,253Q614,160 480,160Q346,160 253,253Q160,346 160,480Q160,614 253,707Q346,800 480,800ZM480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M519,878L519,798Q561,792 600.5,775Q640,758 675,732L733,790Q686,827 632,849.5Q578,872 519,878ZM789,732L733,676Q759,643 775,603.5Q791,564 797,520L879,520Q871,582 848.5,635.5Q826,689 789,732ZM797,440Q791,395 775,355.5Q759,316 733,284L789,228Q827,272 850.5,326Q874,380 879,440L797,440ZM439,878Q286,860 183.5,747Q81,634 81,480Q81,325 183.5,212Q286,99 439,82L439,162Q319,179 240,269Q161,359 161,480Q161,601 240,690.5Q319,780 439,798L439,878ZM677,228Q641,201 601,184Q561,167 519,162L519,82Q578,87 632,109.5Q686,132 733,170L677,228ZM480,680Q422,631 371,575Q320,519 320,444Q320,376 366.5,328Q413,280 480,280Q547,280 593.5,328Q640,376 640,444Q640,519 589,575Q538,631 480,680ZM480,480Q498,480 510.5,467.5Q523,455 523,437Q523,420 510.5,407Q498,394 480,394Q462,394 449.5,407Q437,420 437,437Q437,455 449.5,467.5Q462,480 480,480Z"/>
</vector>
+3
View File
@@ -11,6 +11,7 @@ lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.10.1"
composeBom = "2024.04.01"
logbackClassic = "1.2.11"
sentry = "8.12.0"
splittiesFunPackAndroidBase = "3.0.0"
[libraries]
@@ -31,9 +32,11 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man
androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktor" }
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" }
sentry = { module = "io.sentry:sentry-android", version.ref = "sentry" }
splitties-base = { module = "com.louiscad.splitties:splitties-fun-pack-android-base", version.ref = "splittiesFunPackAndroidBase" }
[plugins]