초기 관제 시스템 뼈대 구성

This commit is contained in:
2026-06-06 14:58:54 +09:00
parent d982777718
commit cab7394b1f
29 changed files with 3221 additions and 1 deletions

29
apps/android/README.md Normal file
View File

@@ -0,0 +1,29 @@
# CargoRadar Android
This is the first driver-app skeleton. It uses a foreground service, Android's
native `LocationManager`, and `HttpURLConnection` so the MVP has no runtime
library dependencies beyond the Android Gradle/Kotlin plugins.
## Build
Open `apps/android` in Android Studio, sync Gradle, then run the `app` module.
Current Gradle plugin choices:
- Android Gradle Plugin: `9.2.0`
- Kotlin: `2.4.0`
- compile/target SDK: `36`
## MVP behavior
- The app asks for foreground location and notification permissions.
- Press `운행 시작` to start a location foreground service.
- The service uploads location JSON to `/api/v1/locations`.
- Failed uploads are queued locally and retried after the next successful send.
- Press `운행 종료` to stop location updates.
For emulator testing, keep the default server URL `http://10.0.2.2:8080`.
For a real phone, use the NAS or PC LAN URL, for example
`http://192.168.0.10:8080`.
Production builds should use HTTPS and disable cleartext traffic.

View File

@@ -0,0 +1,21 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.comtropy.cargoradar"
compileSdk = 36
defaultConfig {
applicationId = "com.comtropy.cargoradar"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
}
kotlin {
jvmToolchain(17)
}

View File

@@ -0,0 +1,33 @@
<?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_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:name=".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=".LocationUploadService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>

View File

@@ -0,0 +1,302 @@
package com.comtropy.cargoradar
import android.Manifest
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.IBinder
import android.util.Base64
import java.net.HttpURLConnection
import java.net.URL
import java.time.Instant
import java.util.Locale
import java.util.concurrent.Executors
class LocationUploadService : Service(), LocationListener {
private val uploadExecutor = Executors.newSingleThreadExecutor()
private lateinit var locationManager: LocationManager
private var serverUrl: String = ""
private var driverId: String = ""
private var vehicleNo: String = ""
private var deviceToken: String = ""
override fun onCreate() {
super.onCreate()
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> {
stopTracking()
return START_NOT_STICKY
}
ACTION_START -> {
serverUrl = intent.getStringExtra(EXTRA_SERVER_URL).orEmpty().trimEnd('/')
driverId = intent.getStringExtra(EXTRA_DRIVER_ID).orEmpty()
vehicleNo = intent.getStringExtra(EXTRA_VEHICLE_NO).orEmpty()
deviceToken = intent.getStringExtra(EXTRA_DEVICE_TOKEN).orEmpty()
startAsForeground("Waiting for location")
startLocationUpdates()
return START_STICKY
}
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
stopTracking()
uploadExecutor.shutdown()
super.onDestroy()
}
override fun onLocationChanged(location: Location) {
updateNotification("Uploading location · ${location.provider}")
uploadLocation(location)
}
@SuppressLint("MissingPermission")
private fun startLocationUpdates() {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
updateNotification("Location permission required")
return
}
val providers = listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER)
.filter { provider -> locationManager.isProviderEnabled(provider) }
if (providers.isEmpty()) {
updateNotification("No location provider enabled")
return
}
for (provider in providers) {
locationManager.requestLocationUpdates(
provider,
MIN_UPDATE_INTERVAL_MS,
MIN_UPDATE_DISTANCE_M,
this
)
locationManager.getLastKnownLocation(provider)?.let(::uploadLocation)
}
}
private fun stopTracking() {
locationManager.removeUpdates(this)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
private fun uploadLocation(location: Location) {
if (serverUrl.isBlank() || driverId.isBlank() || deviceToken.isBlank()) {
updateNotification("Server config required")
return
}
val endpoint = "$serverUrl/api/v1/locations"
val payload = buildLocationPayload(location)
uploadExecutor.execute {
val sent = sendPayload(endpoint, payload)
if (sent) {
flushQueuedLocations(endpoint)
updateNotification("Last upload ok · ${Instant.now()}")
} else {
enqueueLocationPayload(payload)
updateNotification("Upload queued · ${queuedPayloads().size}")
}
}
}
private fun buildLocationPayload(location: Location): String {
val speedKmh = if (location.hasSpeed()) location.speed.toDouble() * 3.6 else null
val heading = if (location.hasBearing()) location.bearing.toDouble() else null
val accuracy = if (location.hasAccuracy()) location.accuracy.toDouble() else null
val recordedAt = Instant.ofEpochMilli(location.time).toString()
return "{" +
"\"driverId\":${json(driverId)}," +
"\"vehicleNo\":${json(vehicleNo)}," +
"\"latitude\":${number(location.latitude)}," +
"\"longitude\":${number(location.longitude)}," +
"\"accuracy\":${nullableNumber(accuracy)}," +
"\"speed\":${nullableNumber(speedKmh)}," +
"\"heading\":${nullableNumber(heading)}," +
"\"provider\":${json(location.provider.orEmpty())}," +
"\"recordedAt\":${json(recordedAt)}" +
"}"
}
private fun sendPayload(endpoint: String, payload: String): Boolean {
return try {
val connection = (URL(endpoint).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = 10000
readTimeout = 10000
doOutput = true
setRequestProperty("Content-Type", "application/json; charset=utf-8")
setRequestProperty("X-Device-Token", deviceToken)
}
connection.outputStream.use { stream ->
stream.write(payload.toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
connection.disconnect()
responseCode in 200..299
} catch (_: Exception) {
false
}
}
private fun flushQueuedLocations(endpoint: String) {
val queued = queuedPayloads()
if (queued.isEmpty()) return
val remaining = mutableListOf<String>()
for (payload in queued) {
if (!sendPayload(endpoint, payload)) {
remaining.add(payload)
}
}
saveQueuedPayloads(remaining)
}
private fun enqueueLocationPayload(payload: String) {
val queued = queuedPayloads().toMutableList()
queued.add(payload)
while (queued.size > MAX_QUEUED_LOCATIONS) {
queued.removeAt(0)
}
saveQueuedPayloads(queued)
}
private fun queuedPayloads(): List<String> {
val encoded = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE)
.getString(KEY_QUEUED_PAYLOADS, "")
.orEmpty()
if (encoded.isBlank()) return emptyList()
return encoded
.lineSequence()
.filter { it.isNotBlank() }
.mapNotNull { line ->
try {
String(Base64.decode(line, Base64.NO_WRAP), Charsets.UTF_8)
} catch (_: Exception) {
null
}
}
.toList()
}
private fun saveQueuedPayloads(payloads: List<String>) {
val encoded = payloads.joinToString("\n") { payload ->
Base64.encodeToString(payload.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
}
getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE)
.edit()
.putString(KEY_QUEUED_PAYLOADS, encoded)
.apply()
}
private fun startAsForeground(content: String) {
val notification = buildNotification(content)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
private fun updateNotification(content: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(content))
}
private fun buildNotification(content: String): Notification {
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
return builder
.setSmallIcon(R.drawable.ic_stat_location)
.setContentTitle(getString(R.string.notification_title))
.setContentText(content)
.setOngoing(true)
.build()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
private fun json(value: String): String {
return "\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
}
private fun number(value: Double): String {
return String.format(Locale.US, "%.7f", value)
}
private fun nullableNumber(value: Double?): String {
return value?.let { String.format(Locale.US, "%.3f", it) } ?: "null"
}
companion object {
const val ACTION_START = "com.comtropy.cargoradar.action.START"
const val ACTION_STOP = "com.comtropy.cargoradar.action.STOP"
const val EXTRA_SERVER_URL = "server_url"
const val EXTRA_DRIVER_ID = "driver_id"
const val EXTRA_VEHICLE_NO = "vehicle_no"
const val EXTRA_DEVICE_TOKEN = "device_token"
private const val CHANNEL_ID = "cargo_radar_location"
private const val NOTIFICATION_ID = 1201
private const val MIN_UPDATE_INTERVAL_MS = 30_000L
private const val MIN_UPDATE_DISTANCE_M = 100f
private const val MAX_QUEUED_LOCATIONS = 500
private const val PREFERENCES_NAME = "cargo_radar_location_service"
private const val KEY_QUEUED_PAYLOADS = "queued_payloads"
}
}

View File

@@ -0,0 +1,222 @@
package com.comtropy.cargoradar
import android.Manifest
import android.app.Activity
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.Settings
import android.text.InputType
import android.view.View
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import kotlin.math.roundToInt
class MainActivity : Activity() {
private lateinit var preferences: SharedPreferences
private lateinit var serverUrlInput: EditText
private lateinit var driverIdInput: EditText
private lateinit var vehicleNoInput: EditText
private lateinit var tokenInput: EditText
private lateinit var statusText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
preferences = getSharedPreferences("cargo_radar_config", MODE_PRIVATE)
setContentView(buildContentView())
}
private fun buildContentView(): View {
val container = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(20), dp(18), dp(20), dp(18))
}
val title = TextView(this).apply {
text = getString(R.string.app_name)
textSize = 24f
setTextColor(0xff17212b.toInt())
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
container.addView(title, matchWrap())
val subtitle = TextView(this).apply {
text = "운행 위치 업로드"
textSize = 14f
setTextColor(0xff657487.toInt())
}
container.addView(subtitle, marginBottom(20))
serverUrlInput = input(
key = "server_url",
defaultValue = "http://10.0.2.2:8080",
hint = "서버 URL"
)
driverIdInput = input("driver_id", "demo-driver", "기사 ID")
vehicleNoInput = input("vehicle_no", "SEOUL-12-3456", "차량 번호")
tokenInput = input("device_token", "demo-token", "기기 토큰")
addField(container, "서버 URL", serverUrlInput)
addField(container, "기사 ID", driverIdInput)
addField(container, "차량 번호", vehicleNoInput)
addField(container, "기기 토큰", tokenInput)
val permissionButton = Button(this).apply {
text = "권한 확인"
setOnClickListener { requestRuntimePermissions() }
}
container.addView(permissionButton, marginTop(8))
val startButton = Button(this).apply {
text = "운행 시작"
setOnClickListener { startTracking() }
}
container.addView(startButton, marginTop(8))
val stopButton = Button(this).apply {
text = "운행 종료"
setOnClickListener { stopTracking() }
}
container.addView(stopButton, marginTop(8))
val settingsButton = Button(this).apply {
text = "앱 위치 설정"
setOnClickListener {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = android.net.Uri.parse("package:$packageName")
})
}
}
container.addView(settingsButton, marginTop(8))
statusText = TextView(this).apply {
text = "대기 중"
textSize = 14f
setTextColor(0xff17212b.toInt())
}
container.addView(statusText, marginTop(18))
return ScrollView(this).apply {
addView(container)
}
}
private fun addField(container: LinearLayout, labelText: String, input: EditText) {
val label = TextView(this).apply {
text = labelText
textSize = 13f
setTextColor(0xff657487.toInt())
setTypeface(typeface, android.graphics.Typeface.BOLD)
}
container.addView(label, marginTop(14))
container.addView(input, matchWrap())
}
private fun input(key: String, defaultValue: String, hint: String): EditText {
return EditText(this).apply {
setText(preferences.getString(key, defaultValue))
this.hint = hint
inputType = InputType.TYPE_CLASS_TEXT
singleLine = true
}
}
private fun requestRuntimePermissions() {
val permissions = mutableListOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
requestPermissions(permissions.toTypedArray(), REQUEST_PERMISSIONS)
}
private fun startTracking() {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
requestRuntimePermissions()
statusText.text = "위치 권한이 필요합니다."
return
}
saveConfig()
val intent = Intent(this, LocationUploadService::class.java).apply {
action = LocationUploadService.ACTION_START
putExtra(LocationUploadService.EXTRA_SERVER_URL, serverUrlInput.text.toString().trim())
putExtra(LocationUploadService.EXTRA_DRIVER_ID, driverIdInput.text.toString().trim())
putExtra(LocationUploadService.EXTRA_VEHICLE_NO, vehicleNoInput.text.toString().trim())
putExtra(LocationUploadService.EXTRA_DEVICE_TOKEN, tokenInput.text.toString().trim())
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
statusText.text = "위치 업로드를 시작했습니다."
}
private fun stopTracking() {
val intent = Intent(this, LocationUploadService::class.java).apply {
action = LocationUploadService.ACTION_STOP
}
startService(intent)
statusText.text = "위치 업로드를 종료했습니다."
}
private fun saveConfig() {
preferences.edit()
.putString("server_url", serverUrlInput.text.toString().trim())
.putString("driver_id", driverIdInput.text.toString().trim())
.putString("vehicle_no", vehicleNoInput.text.toString().trim())
.putString("device_token", tokenInput.text.toString().trim())
.apply()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSIONS && grantResults.any { it == PackageManager.PERMISSION_GRANTED }) {
statusText.text = "권한 확인 완료"
}
}
private fun matchWrap(): LinearLayout.LayoutParams {
return LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
)
}
private fun marginTop(topDp: Int): LinearLayout.LayoutParams {
return matchWrap().apply {
topMargin = dp(topDp)
}
}
private fun marginBottom(bottomDp: Int): LinearLayout.LayoutParams {
return matchWrap().apply {
bottomMargin = dp(bottomDp)
}
}
private fun dp(value: Int): Int {
return (value * resources.displayMetrics.density).roundToInt()
}
companion object {
private const val REQUEST_PERMISSIONS = 1001
}
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,2C8.14,2 5,5.14 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.86 -3.14,-7 -7,-7zM12,12.5c-1.38,0 -2.5,-1.12 -2.5,-2.5S10.62,7.5 12,7.5s2.5,1.12 2.5,2.5S13.38,12.5 12,12.5z" />
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">CargoRadar Driver</string>
<string name="notification_title">CargoRadar 운행 추적</string>
<string name="notification_channel_name">운행 위치 업로드</string>
</resources>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:statusBarColor">#FFFFFF</item>
<item name="android:navigationBarColor">#FFFFFF</item>
</style>
</resources>

View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "9.2.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "CargoRadarAndroid"
include(":app")