<!-- {%hackmd BJrTq20hE %} -->
<!-- a 為地球橢球的長半軸,b 為地球橢球的短半軸,{%hackmd BJrTq20hE %} -->
<!-- dark theme -->
# 做一個抓NMEA的Android App,使用jetpack compose 和 Kotlin
###### tags: `School Homework Note` `jetpack compose` `Kotlin` `Modern Android` `Android`

學校車聯網第二章,定位系統分析
> 需求如下
> 有3點圖標,其中一點為GMS API 之座標
> 第2點為衛星傳輸座標經演算法之後得知
> 第3點為手動輸入,需要自行計算並輸入座標
剛開始先驗證有沒有算錯,架設一個網站來測試
https://nyust-iov-nmea-maps.web.app
## jetpack compose
Jetpack Compose 是 Android 推薦的新型工具包,可用來建構原生 UI。這可簡化及加快 Android 平台上的 UI 開發作業。透過較少的程式碼、強大的工具和直觀的 Kotlin API,讓您的應用程式更貼近生活。
https://developer.android.com/jetpack/compose?hl=zh-tw
使用Android Studio建立專案時使用 jetpack compose

### 更好的使用者體驗
由於才剛學 Kotlin 和 jetpack compose,先不要虐待自己,使用 [google/accompanist](https://github.com/google/accompanist) 中的 System UI Controller for Jetpack Compose 來修改 SystemBar 和 NavigationBar 色彩
先安裝吧
```
repositories {
mavenCentral()
}
dependencies {
implementation "com.google.accompanist:accompanist-systemuicontroller:<version>"
}
```
```kotlin
val systemUiController = rememberSystemUiController()
val useDarkIcons = !isSystemInDarkTheme()
val color = MaterialTheme.colorScheme.background
SideEffect {
systemUiController.setNavigationBarColor(
color = color,
darkIcons = useDarkIcons
)
systemUiController.setStatusBarColor(
color = color,
darkIcons = useDarkIcons
)
}
```
https://google.github.io/accompanist/systemuicontroller/
如果要在導航列或狀態列下方渲染,以前要修改XML檔案,在 jetpack compose 中,可在 `setContent` 前使用
```kotlin
window.setFlags(
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
)
```
> ## 繳交本作業時學習 jetpack compose 和 Kotlin 不足1週,程式碼都有最佳化的空間,請見諒
## 開始
宣告3種經緯度和1個鏡頭起始經緯度
```kotlin
var isLocationPermissionGranted = mutableStateOf(false)
val current_Latitude = mutableStateOf(-1.0)
val current_Longitude = mutableStateOf(-1.0)
var nmea_latitude = mutableStateOf(-1.0)
var nmea_longitude = mutableStateOf(-1.0)
var fist_latitude = mutableStateOf(-1.0)
var fist_longitude = mutableStateOf(-1.0)
```
在主 `@Composable` 方法內宣告
```kotlin
val current = LatLng(current_Latitude.value, current_Longitude.value)
var manualLatitude by remember { mutableStateOf(-1.0) }
var manualLongitude by remember { mutableStateOf(-1.0) }
```
### NMEA
#### NMEA 資料抓取
要抓取原始位置資料需要用到 `LocationManager`
```kotlin
private lateinit var locationManager: LocationManager
```
確認有無權限
```kotlin
private fun InitGPSGettingLogic() {
// create a location manager
locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
val gspEnabled = locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)
if (gspEnabled) {
//gps on
Log.d("NMEA_APP", javaClass.name + ":" + "GPS ON :)")
//check android.permission.ACCESS_FINE_LOCATION and android.permission.ACCESS_COARSE_LOCATION permission whether to enable
// Check if the app has the ACCESS_FINE_LOCATION permission
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
// If the app doesn't have the permission, request it
ActivityCompat.requestPermissions(
this, arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
), 101
)
} else {
// If the app has the permission, access the device's location
// (e.g. to display a map or provide location-based services)
isLocationPermissionGranted.value = true
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
10000,
10000f,
this
)
locationManager.addNmeaListener(this)
}
} else {
Log.d("NMEA_APP", javaClass.name + ":" + "GPS NOT ON")
}
//initialized fusedLocationClient
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
}
```
#### NMEA 資料解析

建立模組和工具供稍後使用
```kotlin
package dev.koukeneko.nmea.data
data class Location(
val latitude: Double,
val longitude: Double
)
```
```kotlin
package dev.koukeneko.nmea.utility
import dev.koukeneko.nmea.data.Location
class NMEAFormatter constructor(
val nmea: String
) {
private var latitude: String = ""
private var longitude: String = ""
private val nmeaArray = nmea.split(",")
fun getLatLong(): Location? {
if (nmeaArray[0] == "\$GNGGA") {
latitude = if (nmeaArray[3] == "S") {
(nmeaArray[2].toInt() * -1).toString()
} else {
nmeaArray[2]
}
longitude = if (nmeaArray[5] == "W") {
//convert longitude to negative by parsing to Int and then back to String
(nmeaArray[4].toInt() * -1).toString()
} else {
nmeaArray[4]
}
latitude =
latitude.substring(0, 2) + '.' + ((latitude.substring(2).replace(Regex("\\."), "")
.toInt() / 60).toString()).replace(".", "")
longitude =
longitude.substring(0, 3) + '.' + ((longitude.substring(3).replace(Regex("\\."), "")
.toInt() / 60).toString()).replace(".", "")
}
// else if (nmeaArray[0] == "\$GNGSA") //多星聯合定位
// {
// TODO
// }
//check if latitude and longitude are empty
if (latitude == "" || longitude == "") {
return null
}
return Location(latitude.toDouble(), longitude.toDouble())
}
}
```
利用 `onNmeaMessage` 來接收 NMEA 訊息,順便更新 GMS 定位
```kotlin
override fun onNmeaMessage(message: String?, timestamp: Long) {
Log.d(
"NMEA_APP",
javaClass.name + ":" + "[" + timestamp + "] " + message + ""
)
Log.d("NMEA_APP_MESSAGE", message.toString())
val nmea = NMEAFormatter(message.toString()).getLatLong()
//check nmea whether is null
if (nmea != null) {
nmea_latitude.value = nmea.latitude
nmea_longitude.value = nmea.longitude
}
Log.d("NMEA_APP", "nmea_latitude : ${nmea_latitude.value}")
Log.d("NMEA_APP", "nmea_longitude : ${nmea_longitude.value}")
//get last location from fusedLocationClient
if (ActivityCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_FINE_LOCATION
) != PackageManager.PERMISSION_GRANTED && ActivityCompat.checkSelfPermission(
this,
Manifest.permission.ACCESS_COARSE_LOCATION
) != PackageManager.PERMISSION_GRANTED
) {
// TODO: Consider calling
// ActivityCompat#requestPermissions
// here to request the missing permissions, and then overriding
// public void onRequestPermissionsResult(int requestCode, String[] permissions,
// int[] grantResults)
// to handle the case where the user grants the permission. See the documentation
// for ActivityCompat#requestPermissions for more details.
return
}
fusedLocationClient.lastLocation
.addOnSuccessListener { location: Location? ->
// Got last known location. In some rare situations this can be null.
if (location != null) {
Log.d("Location", location.toString())
current_Latitude.value = location.latitude
current_Longitude.value = location.longitude
Log.d("Location", "${current_Latitude.value},${current_Longitude.value}")
} else {
Log.d("Location", "Location is null")
}
}
}
```
### Google Maps Component
#### 建立 Google Maps 鏡頭位置 物件
```kotlin
//build camera position
val cameraPosition = rememberCameraPositionState {
position = if (current_Latitude.value != 0.0 && current_Longitude.value != 0.0) {
CameraPosition.fromLatLngZoom(kaohsiung, 10f)
} else {
CameraPosition.fromLatLngZoom(
LatLng(
fist_latitude.value,
fist_longitude.value
), 10f
)
}
Log.d("CameraLocationUpdate", "${fist_latitude.value},${fist_longitude.value}")
}
//if current_Latitude.value and current_Longitude.value change first time, camera position will change
LaunchedEffect(fist_latitude.value, fist_longitude.value) {
cameraPosition.move(
CameraUpdateFactory.newCameraPosition(
CameraPosition.fromLatLngZoom(
LatLng(fist_latitude.value, fist_longitude.value),
17f
)
)
)
}
```
#### GoogleMaps 本體
有2個標記和一個定位圓點,原點使用 Compose 設定中的 `isMyLocationEnabled` 啟用
```kotlin
GoogleMap(
modifier = Modifier,
cameraPositionState = cameraPosition,
uiSettings = settings,
properties = MapProperties(
mapType = MapType.SATELLITE,
isMyLocationEnabled = true,
isBuildingEnabled = true,
)
) {
//if nmea_latitude.value and nmea_longitude.value change, marker will change
Marker(
state = MarkerState(
position = LatLng(
nmea_latitude.value,
nmea_longitude.value
)
),
visible = nmea_latitude.value != -1.0 && nmea_longitude.value != -1.0,
title = "NMEA",
snippet = "${nmea_latitude.value},${nmea_longitude.value}",
icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_RED)
)
Marker(
state = MarkerState(
position = LatLng(
manualLatitude,
manualLongitude
)
),
visible = manualLatitude != -1.0 && manualLatitude != -1.0,
title = "Manual",
snippet = "${manualLatitude},${manualLongitude}",
icon = BitmapDescriptorFactory.defaultMarker(BitmapDescriptorFactory.HUE_GREEN)
)
}
```
### 其他UI
#### 中間座標顯示
中間文字顯示就用 `Text()` Component
```kotlin
Text("GMS API Location", fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary)
Text("${current_Latitude.value},${current_Longitude.value}")
Text("NMEA Location", fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary)
Text("${nmea_latitude.value},${nmea_longitude.value}")
Text("Manual Location", fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.primary)
Text("${manualLatitude},${manualLongitude}")
```
#### 輸入手動座標對話框

需要 `AlertDialog` Component 建立一個訊息對話框
需要有便數據記憶是否開啟以及輸入的文字
```kotlin
var openDialog by remember { mutableStateOf(false) }
var editMessage by remember { mutableStateOf("") }
var editMessage1 by remember { mutableStateOf("") }
```
建立 `AlertDialog` 並使用 `LocalFocusManager` 來管理
```kotlin
val forceManager = LocalFocusManager.current
//open dialog
if (openDialog) {
AlertDialog(
onDismissRequest = { openDialog = false },
title = { Text("Change manual location") },
text = {
Column {
OutlinedTextField(
value = editMessage,
onValueChange = { editMessage = it },
label = { Text("Latitude") },
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next
),
shape = RoundedCornerShape(16.dp),
keyboardActions = KeyboardActions(
onDone = {
forceManager.moveFocus(FocusDirection.Down)
try{
manualLatitude = editMessage.toDouble()
}catch (e:Exception){
//do nothing
}
}
)
)
OutlinedTextField(
value = editMessage1,
onValueChange = { editMessage1 = it },
label = { Text("Longitude") },
keyboardOptions = KeyboardOptions.Default.copy(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
shape = RoundedCornerShape(16.dp),
keyboardActions = KeyboardActions(
onNext = {
forceManager.clearFocus()
try{
manualLongitude = editMessage1.toDouble()
}catch (e:Exception){
//empty String, do nothing
}
}
)
)
}
},
confirmButton = {
Button(
onClick = {
try{
manualLatitude = editMessage.toDouble()
manualLongitude = editMessage1.toDouble()
}catch (e:Exception){
//empty String, do nothing
}
openDialog = false
}
) {
Text("Confirm")
}
},
dismissButton = {
Button(
onClick = {
openDialog = false
}
) {
Text("Cancel")
}
}
)
}
```
## 計算過程
GPS衛星傳遞的訊號包括衛星自身 (a,b,c) 座標資料以及發射時間,那麼 GPS 接收器就可以利用這些資訊計算位址。首先計算衛星與接收器間的距離 r = 光速 x (接收器接收時間 – 衛星發射時間) 。
假設現在有四顆衛星資料,

已知 (X,Y,Z) 座標,參考大地座標系可求出緯度 B 經度 L,

# App 下載
https://github.com/KoukeNeko-Pratices/NYUST_IoV_NMEA_maps/releases/
> 參考自 http://4rdp.blogspot.com/2008/05/gps.html
>本作業Github儲存庫頁面
https://github.com/KoukeNeko-Pratices/NYUST_IoV_NMEA_maps