Compare commits
26 Commits
d982777718
...
e53ee1d183
| Author | SHA1 | Date | |
|---|---|---|---|
| e53ee1d183 | |||
| 8d7d2fea81 | |||
| f3df978769 | |||
| a41d8a7f01 | |||
| fb4b0e4643 | |||
| eecdb35541 | |||
| f7cd6ccae3 | |||
| dcef3ad427 | |||
| e488075e36 | |||
| 1bf346a898 | |||
| 5278b8cf06 | |||
| 3b21cfb9a9 | |||
| e5ae236b8a | |||
| b2562e9145 | |||
| 412acbc846 | |||
| 1dc93c5c78 | |||
| 34bb805400 | |||
| 379b593efd | |||
| 5a1f851d32 | |||
| 2ba63fbc5b | |||
| 2d10927bed | |||
| 84e308a04d | |||
| fbdd87ec8b | |||
| 0f404b439e | |||
| a9a0ab67f4 | |||
| cab7394b1f |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.vs
|
||||
backups
|
||||
tmp
|
||||
runtime-data
|
||||
apps/server/data/*
|
||||
!apps/server/data/.gitkeep
|
||||
node_modules
|
||||
*.log
|
||||
*.zip
|
||||
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
CARGORADAR_PORT=8080
|
||||
ADMIN_TOKEN=
|
||||
ADMIN_AUTH_REQUIRED=false
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
|
||||
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=5000
|
||||
SIMULATOR_SPEED_SCALE=1
|
||||
9
.env.nas.example
Normal file
9
.env.nas.example
Normal file
@@ -0,0 +1,9 @@
|
||||
CARGORADAR_PORT=18081
|
||||
ADMIN_TOKEN=change_this_admin_token
|
||||
ADMIN_AUTH_REQUIRED=false
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
KAKAO_REST_API_KEY=91e5df96769ba97fc83e9ace9a4fec1e
|
||||
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=5000
|
||||
SIMULATOR_SPEED_SCALE=1
|
||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@@ -3,6 +3,10 @@
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
*.jar binary
|
||||
apps/android/gradlew text eol=lf
|
||||
apps/android/gradlew.bat text eol=crlf
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -10,6 +10,18 @@
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# CargoRadar runtime files
|
||||
.env
|
||||
dist/
|
||||
runtime-data/
|
||||
apps/server/data/*
|
||||
!apps/server/data/.gitkeep
|
||||
|
||||
# Android local build files
|
||||
apps/android/.gradle/
|
||||
apps/android/local.properties
|
||||
apps/android/**/build/
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
@@ -360,4 +372,4 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
ENV DATA_DIR=/data
|
||||
ENV WEB_ROOT=/app/apps/web/public
|
||||
|
||||
COPY apps/server/package.json apps/server/package.json
|
||||
COPY apps/server/src apps/server/src
|
||||
COPY apps/web/public apps/web/public
|
||||
|
||||
RUN mkdir -p /data
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
CMD ["node", "apps/server/src/server.js"]
|
||||
257
README.md
Normal file
257
README.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# CargoRadar
|
||||
|
||||
CargoRadar is an MVP for tracking freight drivers from an Android app and
|
||||
monitoring their latest positions from a web control screen.
|
||||
|
||||
## Current Shape
|
||||
|
||||
- `apps/server`: Node.js API server with no external runtime dependencies.
|
||||
- `apps/web`: Static control web UI served by the API server.
|
||||
- `apps/android`: Kotlin Android driver-app skeleton.
|
||||
- `runtime-data`: Docker runtime data directory, created on first run.
|
||||
|
||||
## Run Locally
|
||||
|
||||
If Node.js is installed:
|
||||
|
||||
```powershell
|
||||
node apps/server/src/server.js
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
The web screen has a `샘플 전송` button that posts a demo location with the
|
||||
default `demo-token`.
|
||||
|
||||
By default the server also runs a test simulator with 10 vehicles moving around
|
||||
Korea. Disable it when real driver phones are connected:
|
||||
|
||||
```text
|
||||
SIMULATOR_ENABLED=false
|
||||
```
|
||||
|
||||
## Run With Docker
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
On Synology NAS, the same `docker-compose.yml` can be used through Container
|
||||
Manager or SSH. Keep `runtime-data` mounted so driver tokens and location
|
||||
history survive container restarts.
|
||||
|
||||
For NAS, use `.env.nas.example` as the starting point and prefer port `18081`
|
||||
behind Synology Reverse Proxy.
|
||||
|
||||
During early testing, keep `SIMULATOR_ENABLED=true` to show moving demo
|
||||
vehicles without Android phones. The simulator publishes fresh locations every
|
||||
`SIMULATOR_INTERVAL_MS` milliseconds, 5000 ms by default. It follows cached
|
||||
Kakao Mobility road routes when `KAKAO_REST_API_KEY` is valid, uses OSRM road
|
||||
routes as a demo fallback, and only then falls back to an internal route.
|
||||
`SIMULATOR_SPEED_SCALE` defaults to 1 so the map movement stays close to the
|
||||
displayed vehicle speed.
|
||||
|
||||
## Maintenance Scripts
|
||||
|
||||
Check server status:
|
||||
|
||||
```powershell
|
||||
.\scripts\health-check.ps1 -BaseUrl "http://127.0.0.1:8080"
|
||||
```
|
||||
|
||||
Send a demo location:
|
||||
|
||||
```powershell
|
||||
.\scripts\post-demo-location.ps1 -BaseUrl "http://127.0.0.1:8080"
|
||||
```
|
||||
|
||||
Back up runtime data:
|
||||
|
||||
```powershell
|
||||
.\scripts\backup-runtime-data.ps1
|
||||
```
|
||||
|
||||
## Kakao Map
|
||||
|
||||
The Kakao JavaScript key has a project default in the server config. You can
|
||||
override it in `.env`:
|
||||
|
||||
```text
|
||||
KAKAO_JAVASCRIPT_KEY=<override_key>
|
||||
KAKAO_REST_API_KEY=<directions_key>
|
||||
OSRM_ROUTE_URL=https://router.project-osrm.org/route/v1/driving
|
||||
```
|
||||
|
||||
The REST API key is used only by the server-side simulator to cache road route
|
||||
geometry from Kakao Mobility Directions. If the key has an IP allowlist, add the
|
||||
server/NAS public IP before expecting Kakao-based simulation. Until then, the
|
||||
server can still use OSRM road geometry for demo movement.
|
||||
|
||||
In Kakao Developers, register every web origin that will open the control
|
||||
screen. Useful early entries are:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
http://127.0.0.1:8080
|
||||
http://<NAS-LAN-IP>:18081
|
||||
https://<NAS-domain>
|
||||
```
|
||||
|
||||
If the origin is missing, Kakao's map SDK can return HTTP 403 and the control
|
||||
screen will show a map-key/domain warning.
|
||||
|
||||
## Device Token
|
||||
|
||||
On first server start, the server creates:
|
||||
|
||||
```text
|
||||
runtime-data/drivers.json
|
||||
```
|
||||
|
||||
The default driver is:
|
||||
|
||||
```json
|
||||
{
|
||||
"driverId": "demo-driver",
|
||||
"name": "Demo Driver",
|
||||
"vehicleNo": "SEOUL-12-3456",
|
||||
"token": "demo-token",
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
Change the token before real use.
|
||||
|
||||
## Admin Access
|
||||
|
||||
For sample/demo operation, admin API token checks are disabled by default:
|
||||
|
||||
```text
|
||||
ADMIN_AUTH_REQUIRED=false
|
||||
```
|
||||
|
||||
With this setting, the control screen works regardless of the admin token value
|
||||
entered in the URL or prompt. Before real deployment, set
|
||||
`ADMIN_AUTH_REQUIRED=true` and use a strong `ADMIN_TOKEN`.
|
||||
|
||||
## Test Simulator
|
||||
|
||||
The built-in simulator creates 10 test vehicles with fixed device tokens and
|
||||
routes across Seoul, Incheon, Daejeon, Busan, Gwangju, Ulsan, Gangwon, Jeju,
|
||||
and other regional hubs. It includes several freight vehicle types:
|
||||
|
||||
- `tractor-trailer`: 트레일러
|
||||
- `cargo-truck`: 카고트럭
|
||||
- `box-truck`: 탑차
|
||||
- `van`: 용달차
|
||||
- `refrigerated`: 냉동탑차
|
||||
- `container`: 컨테이너
|
||||
- `wingbody`: 윙바디
|
||||
|
||||
Each simulator tick updates latest positions through the same SSE stream used
|
||||
by real Android uploads, so the control screen behaves like a live fleet map.
|
||||
|
||||
## API
|
||||
|
||||
Driver management:
|
||||
|
||||
```text
|
||||
GET /api/v1/drivers
|
||||
POST /api/v1/drivers
|
||||
PATCH /api/v1/drivers/:driverId
|
||||
DELETE /api/v1/drivers/:driverId
|
||||
```
|
||||
|
||||
Upload a location:
|
||||
|
||||
```powershell
|
||||
$body = @{
|
||||
driverId = "demo-driver"
|
||||
vehicleNo = "SEOUL-12-3456"
|
||||
latitude = 37.5665
|
||||
longitude = 126.9780
|
||||
accuracy = 12
|
||||
speed = 42
|
||||
heading = 90
|
||||
cargoName = "전자부품"
|
||||
cargoQuantity = "36박스"
|
||||
cargoWeight = "1.2t"
|
||||
origin = "서울 상차지"
|
||||
destination = "평택 물류센터"
|
||||
destinationLatitude = 36.9921
|
||||
destinationLongitude = 127.1128
|
||||
routePath = @(
|
||||
@{ latitude = 37.5665; longitude = 126.9780 },
|
||||
@{ latitude = 37.33; longitude = 127.05 },
|
||||
@{ latitude = 36.9921; longitude = 127.1128 }
|
||||
)
|
||||
remainingDistanceKm = 74.5
|
||||
etaMinutes = 68
|
||||
estimatedArrivalAt = (Get-Date).AddMinutes(68).ToUniversalTime().ToString("o")
|
||||
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
} | ConvertTo-Json -Depth 4
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "http://localhost:8080/api/v1/locations" `
|
||||
-Method Post `
|
||||
-Headers @{ "X-Device-Token" = "demo-token" } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
```
|
||||
|
||||
Read latest locations:
|
||||
|
||||
```text
|
||||
GET /api/v1/locations/latest
|
||||
```
|
||||
|
||||
Read history:
|
||||
|
||||
```text
|
||||
GET /api/v1/locations/history?driverId=demo-driver&limit=200
|
||||
```
|
||||
|
||||
Realtime events:
|
||||
|
||||
```text
|
||||
GET /api/v1/events
|
||||
```
|
||||
|
||||
## Android
|
||||
|
||||
Open `apps/android` in Android Studio and run the `app` module.
|
||||
|
||||
- Emulator server URL: `http://10.0.2.2:8080`
|
||||
- Real phone server URL: `http://<NAS-or-PC-LAN-IP>:8080`
|
||||
- Driver ID and device token: use the values from the web `기사` tab.
|
||||
|
||||
For production, use HTTPS and disable Android cleartext traffic.
|
||||
|
||||
## Operation Flow
|
||||
|
||||
1. Start the server on a PC or Synology NAS.
|
||||
2. Open the control web screen.
|
||||
3. Register a driver in the `기사` tab.
|
||||
4. Copy that driver's token into the Android app.
|
||||
5. Start tracking from the Android app.
|
||||
6. Confirm the latest location in the `위치` tab.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Replace JSONL storage with PostgreSQL/PostGIS when history queries become
|
||||
important.
|
||||
- Expand driver/vehicle management with search and editing.
|
||||
- Add route playback and detailed location history filters.
|
||||
- Add route, geofence, and delayed-reception alerts.
|
||||
- Add Synology reverse proxy, HTTPS, and admin authentication for deployment.
|
||||
46
apps/android/README.md
Normal file
46
apps/android/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
For command-line debug builds:
|
||||
|
||||
```powershell
|
||||
$env:JAVA_HOME="C:\Program Files\Android\Android Studio\jbr"
|
||||
$env:ANDROID_HOME="$env:LOCALAPPDATA\Android\Sdk"
|
||||
.\gradlew.bat :app:assembleDebug
|
||||
```
|
||||
|
||||
The debug APK is created at:
|
||||
|
||||
```text
|
||||
apps/android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Current Gradle plugin choices:
|
||||
|
||||
- Android Gradle Plugin: `9.2.0`
|
||||
- Kotlin: built into Android Gradle Plugin 9.x
|
||||
- Gradle wrapper: `9.4.1`
|
||||
- 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.200.129:18081`.
|
||||
|
||||
The current public reverse-proxy URL is
|
||||
`https://cargoradar.comtropy.synology.me`.
|
||||
|
||||
Production builds should use HTTPS and disable cleartext traffic.
|
||||
21
apps/android/app/build.gradle.kts
Normal file
21
apps/android/app/build.gradle.kts
Normal file
@@ -0,0 +1,21 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.comtropy.cargoradar"
|
||||
compileSdk = 36
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.comtropy.cargoradar"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
33
apps/android/app/src/main/AndroidManifest.xml
Normal file
33
apps/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
setSingleLine(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
|
||||
}
|
||||
}
|
||||
10
apps/android/app/src/main/res/drawable/ic_stat_location.xml
Normal file
10
apps/android/app/src/main/res/drawable/ic_stat_location.xml
Normal 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>
|
||||
6
apps/android/app/src/main/res/values/strings.xml
Normal file
6
apps/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
9
apps/android/app/src/main/res/values/styles.xml
Normal file
9
apps/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
3
apps/android/build.gradle.kts
Normal file
3
apps/android/build.gradle.kts
Normal file
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("com.android.application") version "9.2.0" apply false
|
||||
}
|
||||
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
apps/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
apps/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
apps/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
251
apps/android/gradlew
vendored
Normal file
251
apps/android/gradlew
vendored
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
94
apps/android/gradlew.bat
vendored
Normal file
94
apps/android/gradlew.bat
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
18
apps/android/settings.gradle.kts
Normal file
18
apps/android/settings.gradle.kts
Normal 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")
|
||||
1
apps/server/data/.gitkeep
Normal file
1
apps/server/data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
14
apps/server/package.json
Normal file
14
apps/server/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@cargoradar/server",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "CargoRadar MVP API and static control web server.",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "node src/server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
1430
apps/server/src/server.js
Normal file
1430
apps/server/src/server.js
Normal file
File diff suppressed because it is too large
Load Diff
2303
apps/web/public/app.js
Normal file
2303
apps/web/public/app.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
apps/web/public/assets/login-logistics-yard.png
Normal file
BIN
apps/web/public/assets/login-logistics-yard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
261
apps/web/public/index.html
Normal file
261
apps/web/public/index.html
Normal file
@@ -0,0 +1,261 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>CargoRadar 관제</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body class="is-login-active">
|
||||
<section id="loginScreen" class="login-screen" aria-label="CargoRadar 로그인">
|
||||
<div class="login-visual" aria-hidden="true"></div>
|
||||
<div class="login-shade" aria-hidden="true"></div>
|
||||
<div class="login-route-line login-route-line-a" aria-hidden="true"></div>
|
||||
<div class="login-route-line login-route-line-b" aria-hidden="true"></div>
|
||||
|
||||
<div class="login-content">
|
||||
<div class="login-copy">
|
||||
<span class="brand-mark login-brand-mark" aria-hidden="true">CR</span>
|
||||
<span class="login-eyebrow">CargoRadar 관제</span>
|
||||
<h1>운송 현장을 한 화면에서 확인합니다</h1>
|
||||
<p>차량 위치, 화물, 도착 시간을 관제 데스크에서 바로 확인할 수 있습니다.</p>
|
||||
<div class="login-signal-list" aria-label="관제 상태">
|
||||
<span>위치 수신</span>
|
||||
<span>화물 추적</span>
|
||||
<span>경로 확인</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" class="login-card">
|
||||
<span class="section-eyebrow">접속</span>
|
||||
<strong>관제 데스크 로그인</strong>
|
||||
<label>
|
||||
<span>담당자</span>
|
||||
<input id="loginNameInput" name="name" autocomplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
<span>접속 코드</span>
|
||||
<input id="loginCodeInput" name="code" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button type="submit">관제 시작</button>
|
||||
<small>시범 운영</small>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="launchScreen" class="launch-screen" aria-label="관제 연결 상태" aria-live="polite">
|
||||
<div class="launch-panel">
|
||||
<span class="section-eyebrow">연결 확인</span>
|
||||
<strong id="launchTitle">관제 데이터 연결 중</strong>
|
||||
<p id="launchMessage">운행 차량의 최신 위치를 불러오고 있습니다.</p>
|
||||
<div class="launch-progress" aria-hidden="true">
|
||||
<span id="launchProgress"></span>
|
||||
</div>
|
||||
<ol class="launch-steps">
|
||||
<li data-launch-step="0" class="is-active">관제 데이터</li>
|
||||
<li data-launch-step="1">차량 위치</li>
|
||||
<li data-launch-step="2">경로 정보</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand-lockup">
|
||||
<span class="brand-mark" aria-hidden="true">CR</span>
|
||||
<div>
|
||||
<strong class="brand">CargoRadar</strong>
|
||||
<span class="subtitle">실시간 화물 관제</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-context">
|
||||
<span class="topbar-chip">관제 데스크</span>
|
||||
<time id="systemClock" datetime="">--:--</time>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<span id="connectionStatus" class="status-pill">연결 대기</span>
|
||||
<button id="refreshButton" class="toolbar-button" type="button">
|
||||
<span class="button-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img">
|
||||
<path d="M20 12a8 8 0 1 1-2.34-5.66" />
|
||||
<path d="M20 4v6h-6" />
|
||||
</svg>
|
||||
</span>
|
||||
새로고침
|
||||
</button>
|
||||
<button id="demoButton" class="toolbar-button" type="button">
|
||||
<span class="button-icon" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" role="img">
|
||||
<path d="M7 18h10" />
|
||||
<path d="M6 14l2-5h8l2 5" />
|
||||
<path d="M8 14h8" />
|
||||
<path d="M9 18v-2" />
|
||||
<path d="M15 18v-2" />
|
||||
</svg>
|
||||
</span>
|
||||
테스트 전송
|
||||
</button>
|
||||
<button id="logoutButton" class="toolbar-button secondary" type="button">로그아웃</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="workspace">
|
||||
<aside class="sidebar" aria-label="차량 목록">
|
||||
<section class="ops-brief" aria-label="운영 브리핑">
|
||||
<span class="section-eyebrow">운영 현황</span>
|
||||
<strong id="opsHeadline">전국 운송 상태 확인 중</strong>
|
||||
<p id="opsSubline">실시간 위치 수신을 기다리고 있습니다.</p>
|
||||
<div class="ops-brief-grid">
|
||||
<span>
|
||||
<small>평균 남은 시간</small>
|
||||
<strong id="averageEta">-</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>화물 품목</small>
|
||||
<strong id="cargoTypeCount">-</strong>
|
||||
</span>
|
||||
<span>
|
||||
<small>경로 보유</small>
|
||||
<strong id="routePreviewCount">-</strong>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="summary-grid" aria-label="운영 현황">
|
||||
<div class="metric">
|
||||
<span class="metric-label">전체</span>
|
||||
<strong id="totalCount">0</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">수신 중</span>
|
||||
<strong id="activeCount">0</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span class="metric-label">주의</span>
|
||||
<strong id="staleCount">0</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="view-tabs" aria-label="관제 보기">
|
||||
<button id="locationsTab" class="tab-button is-active" type="button">위치</button>
|
||||
<button id="driversTab" class="tab-button" type="button">기사</button>
|
||||
</nav>
|
||||
|
||||
<section id="locationsPanel" class="vehicle-panel">
|
||||
<div class="panel-heading">
|
||||
<h1>차량 위치</h1>
|
||||
<span id="lastUpdated">수신 대기</span>
|
||||
</div>
|
||||
<div class="vehicle-controls">
|
||||
<label class="search-field">
|
||||
<span>검색</span>
|
||||
<input id="vehicleSearchInput" type="search" placeholder="차량, 기사, 화물, 목적지" autocomplete="off" />
|
||||
</label>
|
||||
<div class="status-filters" aria-label="차량 상태 필터">
|
||||
<button class="filter-button is-active" type="button" data-status-filter="all">전체</button>
|
||||
<button class="filter-button" type="button" data-status-filter="active">수신 중</button>
|
||||
<button class="filter-button" type="button" data-status-filter="stale">주의</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vehicleList" class="vehicle-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="driversPanel" class="vehicle-panel is-hidden">
|
||||
<div class="panel-heading">
|
||||
<h1>기사 관리</h1>
|
||||
<span id="driverCount">0명</span>
|
||||
</div>
|
||||
|
||||
<form id="driverForm" class="driver-form">
|
||||
<label>
|
||||
<span>기사명</span>
|
||||
<input id="driverNameInput" name="name" required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>차량번호</span>
|
||||
<input id="vehicleNoInput" name="vehicleNo" required autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>차량 유형</span>
|
||||
<select id="vehicleTypeInput" name="vehicleType">
|
||||
<option value="cargo-truck">카고트럭</option>
|
||||
<option value="tractor-trailer">트레일러</option>
|
||||
<option value="box-truck">탑차</option>
|
||||
<option value="van">용달차</option>
|
||||
<option value="refrigerated">냉동탑차</option>
|
||||
<option value="container">컨테이너</option>
|
||||
<option value="wingbody">윙바디</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>전화번호</span>
|
||||
<input id="phoneInput" name="phone" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
<span>기사 ID</span>
|
||||
<input id="driverIdInput" name="driverId" autocomplete="off" />
|
||||
</label>
|
||||
<button type="submit">기사 등록</button>
|
||||
</form>
|
||||
|
||||
<div id="driverList" class="driver-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="map-panel" aria-label="지도">
|
||||
<div id="map"></div>
|
||||
<div id="mapMessage" class="map-message">지도 로딩 중</div>
|
||||
<section class="map-status-panel" aria-label="지도 운영 상태">
|
||||
<span class="section-eyebrow">운행 지도</span>
|
||||
<strong id="mapStatusTitle">운행 현황</strong>
|
||||
<div class="map-status-grid">
|
||||
<span><b id="mapActiveCount">0</b> 수신 중</span>
|
||||
<span><b id="mapEtaSummary">-</b> 평균 남은 시간</span>
|
||||
<span><b id="mapUpdatedAt">-</b> 최근 수신</span>
|
||||
</div>
|
||||
</section>
|
||||
<section id="routeFocusPanel" class="route-focus-panel" aria-live="polite">
|
||||
<span class="section-eyebrow">차량 정보</span>
|
||||
<strong id="routeFocusTitle">차량 선택</strong>
|
||||
<p id="routeFocusMeta">차량을 선택하면 운행 경로와 도착 시간을 확인할 수 있습니다.</p>
|
||||
</section>
|
||||
<section id="markerActionPanel" class="marker-action-panel is-hidden" aria-label="차량 빠른 선택">
|
||||
<div class="marker-action-head">
|
||||
<div>
|
||||
<span id="markerActionEyebrow" class="section-eyebrow">차량</span>
|
||||
<strong id="markerActionTitle">-</strong>
|
||||
<p id="markerActionMeta">-</p>
|
||||
</div>
|
||||
<button id="markerActionCloseButton" type="button" aria-label="선택 닫기">닫기</button>
|
||||
</div>
|
||||
<div id="markerActionSummary" class="marker-action-summary"></div>
|
||||
<div class="marker-action-buttons">
|
||||
<button type="button" data-marker-action="route">경로 보기</button>
|
||||
<button type="button" data-marker-action="cargo">화물 확인</button>
|
||||
<button type="button" data-marker-action="history">수신 이력</button>
|
||||
<button type="button" data-marker-action="center">지도 중심</button>
|
||||
</div>
|
||||
</section>
|
||||
<div class="map-legend" aria-label="지도 범례">
|
||||
<span><i class="legend-dot active"></i>수신 중</span>
|
||||
<span><i class="legend-dot stale"></i>주의</span>
|
||||
<span><i class="legend-line"></i>목적지 경로</span>
|
||||
</div>
|
||||
<section id="historyDrawer" class="history-drawer is-hidden" aria-label="위치 이력">
|
||||
<div class="history-heading">
|
||||
<div>
|
||||
<strong id="historyTitle">위치 이력</strong>
|
||||
<span id="historySummary">-</span>
|
||||
</div>
|
||||
<button id="historyCloseButton" type="button">닫기</button>
|
||||
</div>
|
||||
<div id="historyList" class="history-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/config.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2201
apps/web/public/styles.css
Normal file
2201
apps/web/public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
34
docker-compose.yml
Normal file
34
docker-compose.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
services:
|
||||
cargoradar:
|
||||
build: .
|
||||
container_name: cargoradar
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${CARGORADAR_PORT:-8080}:8080"
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8080
|
||||
DATA_DIR: /data
|
||||
WEB_ROOT: /app/apps/web/public
|
||||
ADMIN_TOKEN: ${ADMIN_TOKEN:-}
|
||||
ADMIN_AUTH_REQUIRED: ${ADMIN_AUTH_REQUIRED:-false}
|
||||
KAKAO_JAVASCRIPT_KEY: ${KAKAO_JAVASCRIPT_KEY:-}
|
||||
KAKAO_REST_API_KEY: ${KAKAO_REST_API_KEY:-}
|
||||
OSRM_ROUTE_URL: ${OSRM_ROUTE_URL:-https://router.project-osrm.org/route/v1/driving}
|
||||
SIMULATOR_ENABLED: ${SIMULATOR_ENABLED:-true}
|
||||
SIMULATOR_INTERVAL_MS: ${SIMULATOR_INTERVAL_MS:-5000}
|
||||
SIMULATOR_SPEED_SCALE: ${SIMULATOR_SPEED_SCALE:-1}
|
||||
volumes:
|
||||
- ./runtime-data:/data
|
||||
- ./apps/server/src:/app/apps/server/src:ro
|
||||
- ./apps/web/public:/app/apps/web/public:ro
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD
|
||||
- node
|
||||
- -e
|
||||
- "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
29
docs/ARCHITECTURE.md
Normal file
29
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
phone["Android driver app"] -->|POST /api/v1/locations| api["CargoRadar API"]
|
||||
api --> latest["latest-locations.json"]
|
||||
api --> history["locations.jsonl"]
|
||||
web["Control web"] -->|GET latest/history| api
|
||||
api -->|SSE /api/v1/events| web
|
||||
```
|
||||
|
||||
## MVP Data Flow
|
||||
|
||||
1. The Android app starts a foreground location service.
|
||||
2. The service uploads location JSON with `X-Device-Token`.
|
||||
3. The server validates the token against `drivers.json`.
|
||||
4. The server stores the newest location per driver and appends history.
|
||||
5. The control web reads the latest positions and listens for SSE updates.
|
||||
|
||||
## Storage
|
||||
|
||||
The MVP uses simple files:
|
||||
|
||||
- `drivers.json`: driver registry and device tokens.
|
||||
- `latest-locations.json`: current position by driver.
|
||||
- `locations.jsonl`: append-only location history.
|
||||
|
||||
This keeps the first version easy to deploy on Synology NAS. PostgreSQL with
|
||||
PostGIS is the planned upgrade path once route/history queries become central.
|
||||
152
docs/SYNOLOGY.md
Normal file
152
docs/SYNOLOGY.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Synology Deployment
|
||||
|
||||
## Container Manager
|
||||
|
||||
1. Copy this repository or the NAS deploy ZIP to the NAS.
|
||||
2. Extract it into a persistent folder, for example
|
||||
`/docker/cargoradar`.
|
||||
3. Create `.env` from `.env.example`.
|
||||
4. Set `CARGORADAR_PORT` and, if needed, `ADMIN_TOKEN`.
|
||||
5. Create a Container Manager project from `docker-compose.yml`.
|
||||
6. Keep `runtime-data` mapped to persistent NAS storage.
|
||||
|
||||
## Verified NAS Setup
|
||||
|
||||
The first NAS deployment was verified on 2026-06-06 with:
|
||||
|
||||
```text
|
||||
Project name: cargoradar
|
||||
Project path: /volume1/docker/cargoradar/current
|
||||
LAN URL: http://192.168.200.129:18081
|
||||
Public URL: https://cargoradar.comtropy.synology.me
|
||||
```
|
||||
|
||||
The Container Manager build should end with `Exit Code: 0`, and the server
|
||||
health check should report `Health: ok`.
|
||||
|
||||
Keep the real `ADMIN_TOKEN` in the NAS `.env` file, not in committed docs.
|
||||
When creating `.env` remotely, make sure each setting is on its own real line;
|
||||
literal `\n` text inside the file will break Docker Compose port parsing.
|
||||
|
||||
The current reverse proxy rule is:
|
||||
|
||||
```text
|
||||
Source: HTTPS cargoradar.comtropy.synology.me:443
|
||||
Target: HTTP localhost:18081
|
||||
```
|
||||
|
||||
If browsers show an HTTPS trust warning, assign or issue a certificate that
|
||||
covers `cargoradar.comtropy.synology.me`. The reverse proxy can still be smoke
|
||||
tested with `curl -k` while the certificate is being prepared.
|
||||
|
||||
## Environment
|
||||
|
||||
```text
|
||||
CARGORADAR_PORT=8080
|
||||
ADMIN_TOKEN=
|
||||
ADMIN_AUTH_REQUIRED=false
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
```
|
||||
|
||||
For NAS deployment, start from `.env.nas.example` instead. The recommended NAS
|
||||
host port is `18081` because `18080` is already forwarded for `opendaw`.
|
||||
|
||||
```text
|
||||
CARGORADAR_PORT=18081
|
||||
ADMIN_TOKEN=change_this_admin_token
|
||||
ADMIN_AUTH_REQUIRED=false
|
||||
KAKAO_JAVASCRIPT_KEY=07f4728599ceaf4299f77c772a135423
|
||||
SIMULATOR_ENABLED=true
|
||||
SIMULATOR_INTERVAL_MS=4000
|
||||
```
|
||||
|
||||
`ADMIN_AUTH_REQUIRED=false` keeps the sample control screen open regardless of
|
||||
the entered admin token. For real operation, set `ADMIN_AUTH_REQUIRED=true` and
|
||||
use a strong `ADMIN_TOKEN`.
|
||||
Set `SIMULATOR_ENABLED=false` when real Android phones are ready to provide
|
||||
driver locations.
|
||||
If opening the app directly by port, use:
|
||||
|
||||
```text
|
||||
http://<NAS-LAN-IP>:18081/?adminToken=<ADMIN_TOKEN>
|
||||
```
|
||||
|
||||
The better public route is Synology Reverse Proxy:
|
||||
|
||||
```text
|
||||
https://cargoradar.comtropy.synology.me -> http://127.0.0.1:18081
|
||||
```
|
||||
|
||||
## Kakao Domain Entries
|
||||
|
||||
Register every origin that opens the control screen:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
http://127.0.0.1:8080
|
||||
http://192.168.200.129:18081
|
||||
http://<NAS-LAN-IP>:18081
|
||||
https://cargoradar.comtropy.synology.me
|
||||
https://<NAS-domain>
|
||||
```
|
||||
|
||||
Until the NAS origin is registered in Kakao Developers, the control screen can
|
||||
still show driver/location data but the map area will display a Kakao key or
|
||||
domain warning.
|
||||
|
||||
## Android URL
|
||||
|
||||
On the same LAN, use:
|
||||
|
||||
```text
|
||||
http://<NAS-LAN-IP>:18081
|
||||
```
|
||||
|
||||
When reverse proxy and HTTPS are ready, use:
|
||||
|
||||
```text
|
||||
https://cargoradar.comtropy.synology.me
|
||||
```
|
||||
|
||||
## Smoke Test
|
||||
|
||||
After deployment, run from a PC that can reach the NAS:
|
||||
|
||||
```powershell
|
||||
.\scripts\health-check.ps1 -BaseUrl "http://<NAS-LAN-IP>:18081" -AdminToken "<ADMIN_TOKEN>"
|
||||
.\scripts\post-demo-location.ps1 -BaseUrl "http://<NAS-LAN-IP>:18081"
|
||||
```
|
||||
|
||||
Then open the control screen and confirm that the demo location appears in the
|
||||
`위치` tab.
|
||||
|
||||
## Local Package
|
||||
|
||||
Create a NAS-ready ZIP from the repository root:
|
||||
|
||||
```powershell
|
||||
.\scripts\package-nas-deploy.ps1
|
||||
```
|
||||
|
||||
Upload the resulting `dist/cargoradar-nas-*.zip` to the NAS and extract it into
|
||||
the deployment folder.
|
||||
|
||||
## Current Router Context
|
||||
|
||||
Known forwarding entries:
|
||||
|
||||
```text
|
||||
50001 -> 192.168.200.129:5000 DSM HTTP
|
||||
50002 -> 192.168.200.129:5001 DSM HTTPS
|
||||
80 -> 192.168.200.129:80 Web Station / reverse proxy
|
||||
443 -> 192.168.200.129:443 HTTPS reverse proxy
|
||||
3000 -> 192.168.200.129:3000 Gitea
|
||||
2222 -> 192.168.200.129:2222 Gitea SSH
|
||||
50022 -> 192.168.200.129:22 SSH
|
||||
18080 -> 192.168.200.129:18080 opendaw
|
||||
```
|
||||
|
||||
Do not use `18080` for CargoRadar. Prefer a NAS-only host port such as `18081`
|
||||
and publish it through reverse proxy on `443`.
|
||||
24
scripts/backup-runtime-data.ps1
Normal file
24
scripts/backup-runtime-data.ps1
Normal file
@@ -0,0 +1,24 @@
|
||||
param(
|
||||
[string]$DataDir = "runtime-data",
|
||||
[string]$BackupDir = "backups"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = (Resolve-Path ".").Path
|
||||
$resolvedDataDir = Resolve-Path $DataDir
|
||||
|
||||
if (-not $resolvedDataDir.Path.StartsWith($root)) {
|
||||
throw "Refusing to back up a path outside the repository: $($resolvedDataDir.Path)"
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $BackupDir -Force | Out-Null
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$archivePath = Join-Path $BackupDir "cargoradar-runtime-$timestamp.zip"
|
||||
|
||||
Compress-Archive -Path (Join-Path $resolvedDataDir.Path "*") -DestinationPath $archivePath -Force
|
||||
|
||||
[pscustomobject]@{
|
||||
Backup = (Resolve-Path $archivePath).Path
|
||||
} | Format-List
|
||||
32
scripts/health-check.ps1
Normal file
32
scripts/health-check.ps1
Normal file
@@ -0,0 +1,32 @@
|
||||
param(
|
||||
[string]$BaseUrl = "http://127.0.0.1:8080",
|
||||
[string]$AdminToken = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Invoke-CargoRadarJson {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$headers = @{}
|
||||
if ($AdminToken) {
|
||||
$headers["X-Admin-Token"] = $AdminToken
|
||||
}
|
||||
|
||||
Invoke-RestMethod -Uri "$BaseUrl$Path" -Method Get -Headers $headers
|
||||
}
|
||||
|
||||
$health = Invoke-CargoRadarJson -Path "/health"
|
||||
$drivers = Invoke-CargoRadarJson -Path "/api/v1/drivers"
|
||||
$latest = Invoke-CargoRadarJson -Path "/api/v1/locations/latest"
|
||||
|
||||
[pscustomobject]@{
|
||||
BaseUrl = $BaseUrl
|
||||
Health = $health.status
|
||||
ServerTime = $health.time
|
||||
DriverCount = $drivers.drivers.Count
|
||||
LatestLocationCount = $latest.locations.Count
|
||||
} | Format-List
|
||||
76
scripts/package-nas-deploy.ps1
Normal file
76
scripts/package-nas-deploy.ps1
Normal file
@@ -0,0 +1,76 @@
|
||||
param(
|
||||
[string]$OutputDir = "dist"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$root = (Resolve-Path ".").Path
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$archivePath = Join-Path $OutputDir "cargoradar-nas-$timestamp.zip"
|
||||
$staging = Join-Path $OutputDir "cargoradar-nas-stage"
|
||||
|
||||
if (Test-Path $staging) {
|
||||
$resolvedStaging = (Resolve-Path $staging).Path
|
||||
if (-not $resolvedStaging.StartsWith($root)) {
|
||||
throw "Refusing to remove outside the repository: $resolvedStaging"
|
||||
}
|
||||
Remove-Item -LiteralPath $resolvedStaging -Recurse -Force
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $staging | Out-Null
|
||||
$stagingPath = (Resolve-Path $staging).Path
|
||||
|
||||
$items = @(
|
||||
".dockerignore",
|
||||
".env.example",
|
||||
".env.nas.example",
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"README.md",
|
||||
"apps",
|
||||
"docs",
|
||||
"scripts"
|
||||
)
|
||||
|
||||
foreach ($item in $items) {
|
||||
$source = Join-Path $root $item
|
||||
if (-not (Test-Path $source)) {
|
||||
throw "Missing deploy item: $item"
|
||||
}
|
||||
|
||||
Copy-Item -Path $source -Destination $stagingPath -Recurse -Force
|
||||
}
|
||||
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
if (Test-Path $archivePath) {
|
||||
Remove-Item -LiteralPath $archivePath -Force
|
||||
}
|
||||
|
||||
$zip = [System.IO.Compression.ZipFile]::Open($archivePath, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
$files = Get-ChildItem -Path $stagingPath -Recurse -File -Force
|
||||
foreach ($file in $files) {
|
||||
$relativePath = $file.FullName.Substring($stagingPath.Length)
|
||||
$relativePath = $relativePath -replace "^[\\/]+", ""
|
||||
$entryName = $relativePath -replace "\\", "/"
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile(
|
||||
$zip,
|
||||
$file.FullName,
|
||||
$entryName,
|
||||
[System.IO.Compression.CompressionLevel]::Optimal
|
||||
) | Out-Null
|
||||
}
|
||||
}
|
||||
finally {
|
||||
$zip.Dispose()
|
||||
}
|
||||
|
||||
Remove-Item -LiteralPath $stagingPath -Recurse -Force
|
||||
|
||||
[pscustomobject]@{
|
||||
Archive = (Resolve-Path $archivePath).Path
|
||||
} | Format-List
|
||||
29
scripts/post-demo-location.ps1
Normal file
29
scripts/post-demo-location.ps1
Normal file
@@ -0,0 +1,29 @@
|
||||
param(
|
||||
[string]$BaseUrl = "http://127.0.0.1:8080",
|
||||
[string]$DeviceToken = "demo-token",
|
||||
[string]$DriverId = "demo-driver",
|
||||
[string]$VehicleNo = "SEOUL-12-3456",
|
||||
[double]$Latitude = 37.5665,
|
||||
[double]$Longitude = 126.9780
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$body = @{
|
||||
driverId = $DriverId
|
||||
vehicleNo = $VehicleNo
|
||||
latitude = $Latitude
|
||||
longitude = $Longitude
|
||||
accuracy = 12
|
||||
speed = 42
|
||||
heading = 90
|
||||
provider = "script"
|
||||
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
} | ConvertTo-Json
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "$BaseUrl/api/v1/locations" `
|
||||
-Method Post `
|
||||
-Headers @{ "X-Device-Token" = $DeviceToken } `
|
||||
-ContentType "application/json" `
|
||||
-Body $body
|
||||
Reference in New Issue
Block a user