<!-- {%hackmd BJrTq20hE %} --> <!-- a 為地球橢球的長半軸,b 為地球橢球的短半軸,{%hackmd BJrTq20hE %} --> <!-- dark theme --> # 做一個抓NMEA的Android App,使用jetpack compose 和 Kotlin ###### tags: `School Homework Note` `jetpack compose` `Kotlin` `Modern Android` `Android` ![](https://i.imgur.com/ZXQQt03.jpg) 學校車聯網第二章,定位系統分析 > 需求如下 > 有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 ![](https://i.imgur.com/bMGH8mc.png) ### 更好的使用者體驗 由於才剛學 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 資料解析 ![](https://i.imgur.com/k7r2Q1y.png) 建立模組和工具供稍後使用 ```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}") ``` #### 輸入手動座標對話框 ![](https://i.imgur.com/Cu7PfUE.png) 需要 `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 (接收器接收時間 – 衛星發射時間) 。 假設現在有四顆衛星資料, ![](https://i.imgur.com/reirKfu.gif) 已知 (X,Y,Z) 座標,參考大地座標系可求出緯度 B 經度 L, ![](https://i.imgur.com/RWPQeny.gif) # 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