1
0

Compare commits

...

2 Commits

Author SHA1 Message Date
8a54db266f
feat: add sending geocoords 2025-05-03 21:39:04 +03:00
db766c2acc
feat: ask permissions 2025-05-03 17:21:10 +03:00
7 changed files with 284 additions and 17 deletions

View File

@ -67,6 +67,7 @@ dependencies {
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.client.logging) implementation(libs.ktor.client.logging)
implementation(libs.ktor.client.json)
implementation(libs.logback.classic) implementation(libs.logback.classic)
implementation(libs.splitties.base) implementation(libs.splitties.base)

View File

@ -6,7 +6,14 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> 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.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 <application
android:allowBackup="true" android:allowBackup="true"
@ -17,6 +24,7 @@
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.GeoTracker" android:theme="@style/Theme.GeoTracker"
android:usesCleartextTraffic="true"
tools:targetApi="31"> tools:targetApi="31">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
@ -29,6 +37,12 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".LocationForegroundService"
android:foregroundServiceType="location"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@ -9,7 +9,12 @@ import io.ktor.client.*
import io.ktor.client.engine.okhttp.OkHttp import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.logging.* import io.ktor.client.plugins.logging.*
import io.ktor.client.request.get 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.HttpStatusCode
import io.ktor.http.contentType
import org.json.JSONObject
val client = HttpClient(OkHttp) { val client = HttpClient(OkHttp) {
install(Logging) { install(Logging) {
@ -38,6 +43,22 @@ suspend fun health(baseurl: String): Boolean {
/** /**
* Send data to a server * 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("ln", data.ln)
json.put("lt", data.lt)
json.put("nick", data.nick)
client.post("$baseurl/map") {
contentType(ContentType.Application.Json)
setBody(json.toString(2))
}
return true
} catch (e: Exception) {
println("Error: ${e.message}")
return false
}
} }

View File

@ -0,0 +1,76 @@
package ru.risdeveau.geotracker
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
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
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
)
)
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("Service", "onStartCommand")
createNotificationChannel()
val notification = createNotification()
startForeground(1, notification)
locationTracker.startTracking(5000)
return START_STICKY
}
override fun onDestroy() {
locationTracker.stopTracking()
super.onDestroy()
}
private fun createNotificationChannel() {
Log.d("Service", "createNotificationChannel")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
"location_channel",
"Location Tracking",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
private fun createNotification(): Notification {
return NotificationCompat.Builder(this, "location_channel")
.setContentTitle("Отслеживание местоположения")
.setContentText("Обновление каждые 5 секунд")
.setSmallIcon(R.drawable.ic_launcher_foreground)
.build()
}
override fun onBind(intent: Intent?): IBinder? = null
}

View File

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

View File

@ -5,18 +5,27 @@
package ru.risdeveau.geotracker package ru.risdeveau.geotracker
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Done
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -29,21 +38,21 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle import androidx.core.content.ContextCompat
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.intellij.lang.annotations.JdkConstants
import ru.risdeveau.geotracker.ui.theme.GeoTrackerTheme import ru.risdeveau.geotracker.ui.theme.GeoTrackerTheme
import splitties.resources.appColor import splitties.experimental.ExperimentalSplittiesApi
import splitties.init.appCtx
import splitties.resources.appStr import splitties.resources.appStr
import kotlin.apply
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
GeoTrackerTheme { GeoTrackerTheme {
@ -55,10 +64,20 @@ class MainActivity : ComponentActivity() {
var screen by remember { mutableStateOf<Screen>(Screen.Loading) } var screen by remember { mutableStateOf<Screen>(Screen.Loading) }
when (screen) { when (screen) {
Screen.Main -> TODO() Screen.Main -> {
LaunchedEffect(Unit) {
Log.d("Thread", "Starting...")
startLocationService()
Log.d("Thread", "Started")
}
Text("Hello world")
}
Screen.Settings -> { Screen.Settings -> {
Settings(Modifier.align(Alignment.Center)) Settings(Modifier.align(Alignment.Center)) {
screen = Screen.Main
}
} }
Screen.Loading -> { Screen.Loading -> {
@ -90,10 +109,11 @@ sealed class Screen {
object Loading : Screen() object Loading : Screen()
} }
@OptIn(ExperimentalSplittiesApi::class)
@Composable @Composable
fun Settings(modifier: Modifier = Modifier) { fun Settings(modifier: Modifier = Modifier, onConfirm: () -> Unit) {
var username by remember { mutableStateOf("") } var username by remember { mutableStateOf(SettingsPreferences.username) }
var url by remember { mutableStateOf("") } var url by remember { mutableStateOf(SettingsPreferences.url) }
var urlIsValid by remember { mutableStateOf(false) } var urlIsValid by remember { mutableStateOf(false) }
var loading by remember { mutableStateOf(false) } var loading by remember { mutableStateOf(false) }
@ -116,13 +136,85 @@ fun Settings(modifier: Modifier = Modifier) {
OutlinedTextField( OutlinedTextField(
value = url, value = url,
onValueChange = { url = it }, 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)) }, 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) { LocationPermissionScreen()
Button({
SettingsPreferences.username = username.trim()
SettingsPreferences.url = url
onConfirm()
}, enabled = urlIsValid
&& !loading
&& username.trim().isNotEmpty()
&& hasLocationPermissions(appCtx)
) {
Text(appStr(R.string.apply)) Text(appStr(R.string.apply))
} }
} }
}
@Composable
fun LocationPermissionScreen() {
val context = LocalContext.current
val locationPermissions = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION
)
val hasLocationPermissions = remember {
mutableStateOf(
locationPermissions.all {
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
}
)
}
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
hasLocationPermissions.value = permissions.all { it.value }
}
if (hasLocationPermissions.value) {
Button({}, enabled = false) {
Icon(Icons.Outlined.Done, "Done")
Text("Разрешения получены")
}
} else {
Button(onClick = {
permissionLauncher.launch(locationPermissions)
}) {
Text("Получить разрешения")
}
}
}
fun hasLocationPermissions(context: Context): Boolean {
return ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED && ContextCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == 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")
}
} }

View File

@ -31,6 +31,7 @@ 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-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } 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-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", 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" } logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logbackClassic" }