Repo Created
This commit is contained in:
parent
eb305e2886
commit
a8c22c65db
4784 changed files with 329907 additions and 2 deletions
|
|
@ -0,0 +1,88 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
~ SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<manifest xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<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_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.BODY_SENSORS" />
|
||||
|
||||
<uses-permission
|
||||
android:name="android.permission.LOCATION_HARDWARE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.INSTALL_LOCATION_PROVIDER"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.NETWORK_SCAN"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission
|
||||
android:name="android.permission.MODIFY_PHONE_STATE"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<application>
|
||||
<uses-library android:name="com.android.location.provider" />
|
||||
|
||||
<service
|
||||
android:name="org.microg.gms.location.network.NetworkLocationService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="org.microg.gms.location.network.ACTION_NETWORK_LOCATION_SERVICE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="org.microg.gms.location.network.ACTION_NETWORK_IMPORT_EXPORT" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<provider
|
||||
android:name="org.microg.gms.location.network.DatabaseExportFileProvider"
|
||||
android:authorities="${applicationId}.microg.location.export"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/location_exported_files" />
|
||||
</provider>
|
||||
<service
|
||||
android:name="org.microg.gms.location.provider.NetworkLocationProviderService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.WRITE_SECURE_SETTINGS">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.location.service.v2.NetworkLocationProvider" />
|
||||
<action android:name="com.android.location.service.v3.NetworkLocationProvider" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="serviceVersion"
|
||||
android:value="2" />
|
||||
</service>
|
||||
<service
|
||||
android:name="org.microg.gms.location.provider.FusedLocationProviderService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.WRITE_SECURE_SETTINGS">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.location.service.FusedLocationProvider" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="serviceVersion"
|
||||
android:value="2" />
|
||||
</service>
|
||||
<service
|
||||
android:name="org.microg.gms.location.provider.GeocodeProviderService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.WRITE_SECURE_SETTINGS">
|
||||
<intent-filter>
|
||||
<action android:name="com.android.location.service.GeocodeProvider" />
|
||||
<action android:name="com.google.android.location.GeocodeProvider" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="serviceVersion"
|
||||
android:value="2" />
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
|
||||
class DatabaseExportFileProvider : FileProvider() {
|
||||
override fun getType(uri: Uri): String? {
|
||||
try {
|
||||
if (uri.lastPathSegment?.startsWith("cell-") == true) {
|
||||
return "application/vnd.microg.location.cell+csv+gzip"
|
||||
}
|
||||
if (uri.lastPathSegment?.startsWith("wifi-") == true) {
|
||||
return "application/vnd.microg.location.wifi+csv+gzip"
|
||||
}
|
||||
} catch (ignored: Exception) {}
|
||||
return super.getType(uri)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,674 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.DatabaseUtils
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteException
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.contentValuesOf
|
||||
import androidx.core.database.getDoubleOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import androidx.core.os.BundleCompat
|
||||
import org.microg.gms.location.NAME_CELL
|
||||
import org.microg.gms.location.NAME_WIFI
|
||||
import org.microg.gms.location.network.cell.CellDetails
|
||||
import org.microg.gms.location.network.cell.isValid
|
||||
import org.microg.gms.location.network.wifi.WifiDetails
|
||||
import org.microg.gms.location.network.wifi.isRequestable
|
||||
import org.microg.gms.location.network.wifi.macBytes
|
||||
import org.microg.gms.utils.toHexString
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.PrintWriter
|
||||
import java.util.*
|
||||
import java.util.zip.GZIPInputStream
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
private const val CURRENT_VERSION = 8
|
||||
|
||||
internal class LocationDatabase(private val context: Context) : SQLiteOpenHelper(context, "geocache.db", null, CURRENT_VERSION) {
|
||||
private data class Migration(val apply: String?, val revert: String?, val allowApplyFailure: Boolean, val allowRevertFailure: Boolean)
|
||||
private val migrations: Map<Int, List<Migration>>
|
||||
|
||||
init {
|
||||
val migrations = mutableMapOf<Int, MutableList<Migration>>()
|
||||
fun declare(version: Int, apply: String, revert: String? = null, allowFailure: Boolean = false, allowApplyFailure: Boolean = allowFailure, allowRevertFailure: Boolean = allowFailure) {
|
||||
if (!migrations.containsKey(version))
|
||||
migrations[version] = arrayListOf()
|
||||
migrations[version]!!.add(Migration(apply, revert, allowApplyFailure, allowRevertFailure))
|
||||
}
|
||||
|
||||
declare(3, "CREATE TABLE $TABLE_CELLS($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TYPE INTEGER NOT NULL, $FIELD_LAC_TAC INTEGER NOT NULL, $FIELD_CID INTEGER NOT NULL, $FIELD_PSC INTEGER NOT NULL, $FIELD_LATITUDE REAL NOT NULL, $FIELD_LONGITUDE REAL NOT NULL, $FIELD_ACCURACY REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_PRECISION REAL NOT NULL);")
|
||||
declare(3, "CREATE TABLE $TABLE_CELLS_PRE($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TIME INTEGER NOT NULL);")
|
||||
declare(3, "CREATE TABLE $TABLE_WIFIS($FIELD_MAC BLOB, $FIELD_LATITUDE REAL NOT NULL, $FIELD_LONGITUDE REAL NOT NULL, $FIELD_ACCURACY REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_PRECISION REAL NOT NULL);")
|
||||
declare(3, "CREATE TABLE $TABLE_CELLS_LEARN($FIELD_MCC INTEGER NOT NULL, $FIELD_MNC INTEGER NOT NULL, $FIELD_TYPE INTEGER NOT NULL, $FIELD_LAC_TAC INTEGER NOT NULL, $FIELD_CID INTEGER NOT NULL, $FIELD_PSC INTEGER NOT NULL, $FIELD_LATITUDE_HIGH REAL NOT NULL, $FIELD_LATITUDE_LOW REAL NOT NULL, $FIELD_LONGITUDE_HIGH REAL NOT NULL, $FIELD_LONGITUDE_LOW REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_BAD_TIME INTEGER);")
|
||||
declare(3, "CREATE TABLE $TABLE_WIFI_LEARN($FIELD_MAC BLOB, $FIELD_LATITUDE_HIGH REAL NOT NULL, $FIELD_LATITUDE_LOW REAL NOT NULL, $FIELD_LONGITUDE_HIGH REAL NOT NULL, $FIELD_LONGITUDE_LOW REAL NOT NULL, $FIELD_TIME INTEGER NOT NULL, $FIELD_BAD_TIME INTEGER);")
|
||||
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS}_index ON $TABLE_CELLS($FIELD_MCC, $FIELD_MNC, $FIELD_TYPE, $FIELD_LAC_TAC, $FIELD_CID, $FIELD_PSC);")
|
||||
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS_PRE}_index ON $TABLE_CELLS_PRE($FIELD_MCC, $FIELD_MNC);")
|
||||
declare(3, "CREATE UNIQUE INDEX ${TABLE_WIFIS}_index ON $TABLE_WIFIS($FIELD_MAC);")
|
||||
declare(3, "CREATE UNIQUE INDEX ${TABLE_CELLS_LEARN}_index ON $TABLE_CELLS_LEARN($FIELD_MCC, $FIELD_MNC, $FIELD_TYPE, $FIELD_LAC_TAC, $FIELD_CID, $FIELD_PSC);")
|
||||
declare(3, "CREATE UNIQUE INDEX ${TABLE_WIFI_LEARN}_index ON $TABLE_WIFI_LEARN($FIELD_MAC);")
|
||||
declare(3, "CREATE INDEX ${TABLE_CELLS}_time_index ON $TABLE_CELLS($FIELD_TIME);")
|
||||
declare(3, "CREATE INDEX ${TABLE_CELLS_PRE}_time_index ON $TABLE_CELLS_PRE($FIELD_TIME);")
|
||||
declare(3, "CREATE INDEX ${TABLE_WIFIS}_time_index ON $TABLE_WIFIS($FIELD_TIME);")
|
||||
declare(3, "CREATE INDEX ${TABLE_CELLS_LEARN}_time_index ON $TABLE_CELLS_LEARN($FIELD_TIME);")
|
||||
declare(3, "CREATE INDEX ${TABLE_WIFI_LEARN}_time_index ON $TABLE_WIFI_LEARN($FIELD_TIME);")
|
||||
declare(3, "DROP TABLE IF EXISTS $TABLE_WIFI_SCANS;", allowFailure = true)
|
||||
|
||||
declare(4, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_LEARN_RECORD_COUNT INTEGER;")
|
||||
declare(4, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_LEARN_RECORD_COUNT INTEGER;")
|
||||
|
||||
declare(5, "ALTER TABLE $TABLE_CELLS ADD COLUMN $FIELD_ALTITUDE REAL;")
|
||||
declare(5, "ALTER TABLE $TABLE_CELLS ADD COLUMN $FIELD_ALTITUDE_ACCURACY REAL;")
|
||||
declare(5, "ALTER TABLE $TABLE_WIFIS ADD COLUMN $FIELD_ALTITUDE REAL;")
|
||||
declare(5, "ALTER TABLE $TABLE_WIFIS ADD COLUMN $FIELD_ALTITUDE_ACCURACY REAL;")
|
||||
|
||||
declare(6, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_ALTITUDE_HIGH REAL;")
|
||||
declare(6, "ALTER TABLE $TABLE_CELLS_LEARN ADD COLUMN $FIELD_ALTITUDE_LOW REAL;")
|
||||
declare(6, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_ALTITUDE_HIGH REAL;")
|
||||
declare(6, "ALTER TABLE $TABLE_WIFI_LEARN ADD COLUMN $FIELD_ALTITUDE_LOW REAL;")
|
||||
|
||||
declare(8, "DELETE FROM $TABLE_WIFIS WHERE $FIELD_ACCURACY = 0.0 AND ($FIELD_LATITUDE != 0.0 OR $FIELD_LONGITUDE != 0.0);", allowRevertFailure = true)
|
||||
declare(8, "DELETE FROM $TABLE_CELLS WHERE $FIELD_ACCURACY = 0.0 AND ($FIELD_LATITUDE != 0.0 OR $FIELD_LONGITUDE != 0.0);", allowRevertFailure = true)
|
||||
declare(8, "UPDATE $TABLE_CELLS_LEARN SET $FIELD_ALTITUDE_LOW = NULL WHERE $FIELD_ALTITUDE_LOW = 0.0;", allowRevertFailure = true)
|
||||
declare(8, "UPDATE $TABLE_CELLS_LEARN SET $FIELD_ALTITUDE_HIGH = NULL WHERE $FIELD_ALTITUDE_HIGH = 0.0;", allowRevertFailure = true)
|
||||
declare(8, "UPDATE $TABLE_WIFI_LEARN SET $FIELD_ALTITUDE_LOW = NULL WHERE $FIELD_ALTITUDE_LOW = 0.0;", allowRevertFailure = true)
|
||||
declare(8, "UPDATE $TABLE_WIFI_LEARN SET $FIELD_ALTITUDE_HIGH = NULL WHERE $FIELD_ALTITUDE_HIGH = 0.0;", allowRevertFailure = true)
|
||||
|
||||
this.migrations = migrations
|
||||
}
|
||||
|
||||
private fun migrate(db: SQLiteDatabase, oldVersion: Int, newVersion: Int, allowFailure: Boolean = false) {
|
||||
var currentVersion = oldVersion
|
||||
while (currentVersion < newVersion) {
|
||||
val nextVersion = currentVersion + 1
|
||||
val migrations = this.migrations[nextVersion].orEmpty()
|
||||
for (migration in migrations) {
|
||||
if (migration.apply == null && !migration.allowApplyFailure && !allowFailure)
|
||||
throw SQLiteException("Incomplete migration from $currentVersion to $nextVersion")
|
||||
try {
|
||||
db.execSQL(migration.apply)
|
||||
Log.d(TAG, "Applied migration from version $currentVersion to $nextVersion: ${migration.apply}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error while applying migration from version $currentVersion to $nextVersion: ${migration.apply}", e)
|
||||
if (!migration.allowApplyFailure && !allowFailure)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
currentVersion = nextVersion
|
||||
}
|
||||
while (currentVersion > newVersion) {
|
||||
val nextVersion = currentVersion - 1
|
||||
val migrations = this.migrations[currentVersion].orEmpty()
|
||||
for (migration in migrations.asReversed()) {
|
||||
if (migration.revert == null && !migration.allowRevertFailure && !allowFailure)
|
||||
throw SQLiteException("Incomplete migration from $currentVersion to $nextVersion")
|
||||
try {
|
||||
db.execSQL(migration.revert)
|
||||
Log.d(TAG, "Reverted migration from version $currentVersion to $nextVersion: ${migration.revert}")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Error while reverting migration from version $currentVersion to $nextVersion: ${migration.revert}", e)
|
||||
if (!migration.allowRevertFailure && !allowFailure)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
currentVersion = nextVersion
|
||||
}
|
||||
Log.i(TAG, "Migrated from $oldVersion to $newVersion")
|
||||
}
|
||||
|
||||
private fun SQLiteDatabase.query(table: String, columns: Array<String>, selection: String? = null, selectionArgs: Array<String>? = null) =
|
||||
query(table, columns, selection, selectionArgs, null, null, null)
|
||||
|
||||
fun getCellLocation(cell: CellDetails, allowLearned: Boolean = true): Location? {
|
||||
var cursor = readableDatabase.query(TABLE_CELLS, FIELDS_CACHE_LOCATION, CELLS_SELECTION, getCellSelectionArgs(cell))
|
||||
val cellLocation = cursor.getSingleLocation(MAX_CACHE_AGE)
|
||||
if (allowLearned) {
|
||||
cursor = readableDatabase.query(TABLE_CELLS_LEARN, FIELDS_MID_LOCATION_GET_LEARN, CELLS_SELECTION, getCellSelectionArgs(cell))
|
||||
try {
|
||||
if (cursor.moveToNext()) {
|
||||
val badTime = cursor.getLong(8)
|
||||
val time = cursor.getLong(7)
|
||||
if (badTime < time - LEARN_BAD_CUTOFF) {
|
||||
cursor.getCellMidLocation()?.let {
|
||||
if (cellLocation == null || cellLocation == NEGATIVE_CACHE_ENTRY || cellLocation.precision < it.precision) return it
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
if (cellLocation != null) return cellLocation
|
||||
cursor = readableDatabase.query(TABLE_CELLS_PRE, arrayOf(FIELD_TIME), CELLS_PRE_SELECTION, getCellPreSelectionArgs(cell))
|
||||
try {
|
||||
if (cursor.moveToNext()) {
|
||||
if (cursor.getLong(1) > System.currentTimeMillis() - MAX_CACHE_AGE) {
|
||||
return NEGATIVE_CACHE_ENTRY
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getWifiLocation(wifi: WifiDetails, allowLearned: Boolean = true): Location? {
|
||||
var cursor = readableDatabase.query(TABLE_WIFIS, FIELDS_CACHE_LOCATION, getWifiSelection(wifi))
|
||||
val wifiLocation = cursor.getSingleLocation(MAX_CACHE_AGE)
|
||||
if (allowLearned) {
|
||||
cursor = readableDatabase.query(TABLE_WIFI_LEARN, FIELDS_MID_LOCATION_GET_LEARN, getWifiSelection(wifi))
|
||||
try {
|
||||
if (cursor.moveToNext()) {
|
||||
val badTime = cursor.getLong(8)
|
||||
val time = cursor.getLong(7)
|
||||
if (badTime < time - LEARN_BAD_CUTOFF) {
|
||||
cursor.getWifiMidLocation()?.let {
|
||||
if (wifiLocation == null || wifiLocation == NEGATIVE_CACHE_ENTRY || wifiLocation.precision < it.precision) return it
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
}
|
||||
return wifiLocation
|
||||
}
|
||||
|
||||
fun putCellLocation(cell: CellDetails, location: Location) {
|
||||
if (!cell.isValid) return
|
||||
val cv = contentValuesOf(
|
||||
FIELD_MCC to cell.mcc,
|
||||
FIELD_MNC to cell.mnc,
|
||||
FIELD_LAC_TAC to (cell.lac ?: cell.tac ?: 0),
|
||||
FIELD_TYPE to cell.type.ordinal,
|
||||
FIELD_CID to cell.cid,
|
||||
FIELD_PSC to (cell.pscOrPci ?: 0)
|
||||
).apply { putLocation(location) }
|
||||
writableDatabase.insertWithOnConflict(TABLE_CELLS, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
fun putWifiLocation(wifi: WifiDetails, location: Location) {
|
||||
if (!wifi.isRequestable) return
|
||||
val cv = contentValuesOf(
|
||||
FIELD_MAC to wifi.macBytes
|
||||
).apply { putLocation(location) }
|
||||
writableDatabase.insertWithOnConflict(TABLE_WIFIS, null, cv, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
}
|
||||
|
||||
fun learnCellLocation(cell: CellDetails, location: Location, import: Boolean = false): Boolean {
|
||||
if (!cell.isValid) return false
|
||||
val cursor = readableDatabase.query(TABLE_CELLS_LEARN, FIELDS_MID_LOCATION, CELLS_SELECTION, getCellSelectionArgs(cell))
|
||||
var exists = false
|
||||
var isBad = false
|
||||
var midLocation: Location? = null
|
||||
try {
|
||||
if (cursor.moveToNext()) {
|
||||
midLocation = cursor.getMidLocation()
|
||||
exists = midLocation != null
|
||||
isBad = midLocation?.let { it.distanceTo(location) > LEARN_BAD_SIZE_CELL } == true
|
||||
}
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
if (exists && isBad) {
|
||||
val values = ContentValues().apply { putLearnLocation(location, badTime = location.time, import = import) }
|
||||
writableDatabase.update(TABLE_CELLS_LEARN, values, CELLS_SELECTION, getCellSelectionArgs(cell))
|
||||
} else if (!exists) {
|
||||
val values = contentValuesOf(
|
||||
FIELD_MCC to cell.mcc,
|
||||
FIELD_MNC to cell.mnc,
|
||||
FIELD_LAC_TAC to (cell.lac ?: cell.tac ?: 0),
|
||||
FIELD_TYPE to cell.type.ordinal,
|
||||
FIELD_CID to cell.cid,
|
||||
FIELD_PSC to (cell.pscOrPci ?: 0),
|
||||
).apply { putLearnLocation(location, badTime = 0) }
|
||||
writableDatabase.insertWithOnConflict(TABLE_CELLS_LEARN, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
} else {
|
||||
val values = ContentValues().apply { putLearnLocation(location, midLocation) }
|
||||
writableDatabase.update(TABLE_CELLS_LEARN, values, CELLS_SELECTION, getCellSelectionArgs(cell))
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun learnWifiLocation(wifi: WifiDetails, location: Location, import: Boolean = false): Boolean {
|
||||
if (!wifi.isRequestable) return false
|
||||
val cursor = readableDatabase.query(TABLE_WIFI_LEARN, FIELDS_MID_LOCATION, getWifiSelection(wifi))
|
||||
var exists = false
|
||||
var isBad = false
|
||||
var midLocation: Location? = null
|
||||
try {
|
||||
if (cursor.moveToNext()) {
|
||||
midLocation = cursor.getMidLocation()
|
||||
exists = midLocation != null
|
||||
isBad = midLocation?.let { it.distanceTo(location) > LEARN_BAD_SIZE_WIFI } == true
|
||||
}
|
||||
} finally {
|
||||
cursor.close()
|
||||
}
|
||||
if (exists && isBad) {
|
||||
val values = ContentValues().apply { putLearnLocation(location, badTime = location.time, import = import) }
|
||||
writableDatabase.update(TABLE_WIFI_LEARN, values, getWifiSelection(wifi), null)
|
||||
} else if (!exists) {
|
||||
val values = contentValuesOf(
|
||||
FIELD_MAC to wifi.macBytes
|
||||
).apply { putLearnLocation(location, badTime = 0) }
|
||||
writableDatabase.insertWithOnConflict(TABLE_WIFI_LEARN, null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
} else {
|
||||
val values = ContentValues().apply { putLearnLocation(location, midLocation) }
|
||||
writableDatabase.update(TABLE_WIFI_LEARN, values, getWifiSelection(wifi), null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun exportLearned(name: String): Uri? {
|
||||
try {
|
||||
val wifi = when (name) {
|
||||
NAME_WIFI -> true
|
||||
NAME_CELL -> false
|
||||
else -> throw IllegalArgumentException()
|
||||
}
|
||||
val fieldNames = if (wifi) FIELDS_WIFI else FIELDS_CELL
|
||||
val tableName = if (wifi) TABLE_WIFI_LEARN else TABLE_CELLS_LEARN
|
||||
val midLocationGetter: (Cursor) -> Location? = if (wifi) Cursor::getWifiMidLocation else Cursor::getCellMidLocation
|
||||
|
||||
val exportDir = File(context.cacheDir, "location")
|
||||
exportDir.mkdir()
|
||||
val exportFile = File(exportDir, "$name-${UUID.randomUUID()}.csv.gz")
|
||||
val output = GZIPOutputStream(exportFile.outputStream()).bufferedWriter()
|
||||
output.write("${fieldNames.joinToString(",")},${FIELDS_EXPORT_DATA.joinToString(",")}\n")
|
||||
val cursor = readableDatabase.query(tableName, FIELDS_MID_LOCATION + fieldNames)
|
||||
val indices = fieldNames.map { cursor.getColumnIndexOrThrow(it) }
|
||||
while (cursor.moveToNext()) {
|
||||
val midLocation = midLocationGetter(cursor)
|
||||
if (midLocation != null) {
|
||||
output.write(indices.joinToString(",") { index ->
|
||||
if (cursor.getType(index) == Cursor.FIELD_TYPE_BLOB) {
|
||||
cursor.getBlob(index).toHexString()
|
||||
} else {
|
||||
cursor.getStringOrNull(index) ?: ""
|
||||
}
|
||||
})
|
||||
output.write(",${midLocation.latitude},${midLocation.longitude},${if (midLocation.hasAltitude()) midLocation.altitude else ""}\n")
|
||||
}
|
||||
}
|
||||
output.close()
|
||||
return FileProvider.getUriForFile(context,"${context.packageName}.microg.location.export", exportFile)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun importLearned(fileUri: Uri): Int {
|
||||
var counter = 0
|
||||
try {
|
||||
val type = context.contentResolver.getType(fileUri)
|
||||
val gzip = if (type == null || type !in SUPPORTED_TYPES) {
|
||||
if (fileUri.path == null) throw IllegalArgumentException("Unsupported file extension")
|
||||
if (fileUri.path!!.endsWith(".gz")) {
|
||||
true
|
||||
} else if (fileUri.path!!.endsWith(".csv")) {
|
||||
false
|
||||
} else {
|
||||
throw IllegalArgumentException("Unsupported file extension")
|
||||
}
|
||||
} else {
|
||||
type.endsWith("gzip")
|
||||
}
|
||||
val desc = context.contentResolver.openFileDescriptor(fileUri, "r") ?: throw FileNotFoundException()
|
||||
ParcelFileDescriptor.AutoCloseInputStream(desc).use { source ->
|
||||
val input = (if (gzip) GZIPInputStream(source) else source).bufferedReader()
|
||||
val headers = input.readLine().split(",")
|
||||
val name = when {
|
||||
headers.containsAll(FIELDS_WIFI.toList()) && headers.containsAll(FIELDS_EXPORT_DATA.toList()) -> NAME_WIFI
|
||||
headers.containsAll(FIELDS_CELL.toList()) && headers.containsAll(FIELDS_EXPORT_DATA.toList()) -> NAME_CELL
|
||||
else -> null
|
||||
}
|
||||
if (name != null) {
|
||||
while (true) {
|
||||
val line = input.readLine().split(",")
|
||||
if (line.size != headers.size) break // End of file reached
|
||||
val location = Location(PROVIDER_CACHE)
|
||||
location.latitude = line[headers.indexOf(FIELD_LATITUDE)].toDoubleOrNull() ?: continue
|
||||
location.longitude = line[headers.indexOf(FIELD_LONGITUDE)].toDoubleOrNull() ?: continue
|
||||
line[headers.indexOf(FIELD_ALTITUDE)].toDoubleOrNull()?.let { location.altitude = it }
|
||||
location.time = headers.indexOf(FIELD_TIME).takeIf { it != -1 }?.let { line[it] }?.toLongOrNull()?.takeIf { it > 0 } ?: System.currentTimeMillis()
|
||||
if (name == NAME_WIFI) {
|
||||
val wifi = WifiDetails(
|
||||
macAddress = line[headers.indexOf(FIELD_MAC)]
|
||||
)
|
||||
if (learnWifiLocation(wifi, location)) counter++
|
||||
} else {
|
||||
val cell = CellDetails(
|
||||
type = line[headers.indexOf(FIELD_TYPE)].let {
|
||||
it.toIntOrNull()?.let { CellDetails.Companion.Type.entries[it] } ?:
|
||||
runCatching { CellDetails.Companion.Type.valueOf(it) }.getOrNull()
|
||||
} ?: continue,
|
||||
mcc = line[headers.indexOf(FIELD_MCC)].toIntOrNull() ?: continue,
|
||||
mnc = line[headers.indexOf(FIELD_MNC)].toIntOrNull() ?: continue,
|
||||
lac = line[headers.indexOf(FIELD_LAC_TAC)].toIntOrNull(),
|
||||
tac = line[headers.indexOf(FIELD_LAC_TAC)].toIntOrNull(),
|
||||
cid = line[headers.indexOf(FIELD_CID)].toLongOrNull() ?: continue,
|
||||
pscOrPci = line[headers.indexOf(FIELD_PSC)].toIntOrNull(),
|
||||
)
|
||||
if (learnCellLocation(cell, location)) counter++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, e)
|
||||
}
|
||||
return counter
|
||||
}
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
migrate(db, 0, CURRENT_VERSION)
|
||||
}
|
||||
|
||||
fun cleanup(db: SQLiteDatabase) {
|
||||
db.delete(TABLE_CELLS, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
|
||||
db.delete(TABLE_CELLS_PRE, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
|
||||
db.delete(TABLE_WIFIS, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_CACHE_AGE).toString()))
|
||||
db.delete(TABLE_CELLS_LEARN, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_LEARN_AGE).toString()))
|
||||
db.delete(TABLE_WIFI_LEARN, "$FIELD_TIME < ?", arrayOf((System.currentTimeMillis() - MAX_LEARN_AGE).toString()))
|
||||
}
|
||||
|
||||
override fun onOpen(db: SQLiteDatabase) {
|
||||
super.onOpen(db)
|
||||
if (!db.isReadOnly) {
|
||||
cleanup(db)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
migrate(db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
migrate(db, oldVersion, newVersion)
|
||||
}
|
||||
|
||||
fun dump(writer: PrintWriter) {
|
||||
writer.println("Database: cells(cached)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_CELLS)}, cells(learnt)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_CELLS_LEARN)}, wifis(cached)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_WIFIS)}, wifis(learnt)=${DatabaseUtils.queryNumEntries(readableDatabase, TABLE_WIFI_LEARN)}")
|
||||
}
|
||||
}
|
||||
|
||||
const val EXTRA_HIGH_LOCATION = "high"
|
||||
const val EXTRA_LOW_LOCATION = "low"
|
||||
const val EXTRA_RECORD_COUNT = "recs"
|
||||
private const val MAX_CACHE_AGE = 1000L * 60 * 60 * 24 * 14 // 14 days
|
||||
private const val MAX_LEARN_AGE = 1000L * 60 * 60 * 24 * 365 // 1 year
|
||||
private const val TABLE_CELLS = "cells"
|
||||
private const val TABLE_CELLS_PRE = "cells_pre"
|
||||
private const val TABLE_WIFIS = "wifis"
|
||||
private const val TABLE_WIFI_SCANS = "wifi_scans"
|
||||
private const val TABLE_CELLS_LEARN = "cells_learn"
|
||||
private const val TABLE_WIFI_LEARN = "wifis_learn"
|
||||
private const val FIELD_MCC = "mcc"
|
||||
private const val FIELD_MNC = "mnc"
|
||||
private const val FIELD_TYPE = "type"
|
||||
private const val FIELD_LAC_TAC = "lac"
|
||||
private const val FIELD_CID = "cid"
|
||||
private const val FIELD_PSC = "psc"
|
||||
private const val FIELD_LATITUDE = "lat"
|
||||
private const val FIELD_LONGITUDE = "lon"
|
||||
private const val FIELD_ACCURACY = "acc"
|
||||
private const val FIELD_ALTITUDE = "alt"
|
||||
private const val FIELD_ALTITUDE_ACCURACY = "alt_acc"
|
||||
private const val FIELD_TIME = "time"
|
||||
private const val FIELD_PRECISION = "prec"
|
||||
private const val FIELD_MAC = "mac"
|
||||
private const val FIELD_SCAN_HASH = "hash"
|
||||
private const val FIELD_LATITUDE_HIGH = "lath"
|
||||
private const val FIELD_LATITUDE_LOW = "latl"
|
||||
private const val FIELD_LONGITUDE_HIGH = "lonh"
|
||||
private const val FIELD_LONGITUDE_LOW = "lonl"
|
||||
private const val FIELD_ALTITUDE_HIGH = "alth"
|
||||
private const val FIELD_ALTITUDE_LOW = "altl"
|
||||
private const val FIELD_BAD_TIME = "btime"
|
||||
private const val FIELD_LEARN_RECORD_COUNT = "recs"
|
||||
|
||||
private const val LEARN_BASE_ACCURACY_CELL = 10_000.0
|
||||
private const val LEARN_BASE_VERTICAL_ACCURACY_CELL = 5_000.0
|
||||
private const val LEARN_BASE_PRECISION_CELL = 0.5
|
||||
private const val LEARN_ACCURACY_FACTOR_CELL = 0.002
|
||||
private const val LEARN_VERTICAL_ACCURACY_FACTOR_CELL = 0.002
|
||||
private const val LEARN_PRECISION_FACTOR_CELL = 0.01
|
||||
private const val LEARN_BAD_SIZE_CELL = 10_000
|
||||
|
||||
private const val LEARN_BASE_ACCURACY_WIFI = 200.0
|
||||
private const val LEARN_BASE_VERTICAL_ACCURACY_WIFI = 100.0
|
||||
private const val LEARN_BASE_PRECISION_WIFI = 0.2
|
||||
private const val LEARN_ACCURACY_FACTOR_WIFI = 0.02
|
||||
private const val LEARN_VERTICAL_ACCURACY_FACTOR_WIFI = 0.02
|
||||
private const val LEARN_PRECISION_FACTOR_WIFI = 0.1
|
||||
private const val LEARN_BAD_SIZE_WIFI = 200
|
||||
|
||||
private const val LEARN_BAD_CUTOFF = 1000L * 60 * 60 * 24 * 14
|
||||
|
||||
private const val CELLS_SELECTION = "$FIELD_MCC = ? AND $FIELD_MNC = ? AND $FIELD_TYPE = ? AND $FIELD_LAC_TAC = ? AND $FIELD_CID = ? AND $FIELD_PSC = ?"
|
||||
private const val CELLS_PRE_SELECTION = "$FIELD_MCC = ? AND $FIELD_MNC = ?"
|
||||
|
||||
private val FIELDS_CACHE_LOCATION = arrayOf(FIELD_LATITUDE, FIELD_LONGITUDE, FIELD_ACCURACY, FIELD_ALTITUDE, FIELD_ALTITUDE_ACCURACY, FIELD_TIME, FIELD_PRECISION)
|
||||
private val FIELDS_MID_LOCATION = arrayOf(FIELD_LATITUDE_HIGH, FIELD_LATITUDE_LOW, FIELD_LONGITUDE_HIGH, FIELD_LONGITUDE_LOW, FIELD_ALTITUDE_HIGH, FIELD_ALTITUDE_LOW, FIELD_LEARN_RECORD_COUNT, FIELD_TIME)
|
||||
private val FIELDS_MID_LOCATION_GET_LEARN = FIELDS_MID_LOCATION + FIELD_BAD_TIME
|
||||
private val FIELDS_CELL = arrayOf(FIELD_MCC, FIELD_MNC, FIELD_TYPE, FIELD_LAC_TAC, FIELD_CID, FIELD_PSC)
|
||||
private val FIELDS_WIFI = arrayOf(FIELD_MAC)
|
||||
private val FIELDS_EXPORT_DATA = arrayOf(FIELD_LATITUDE, FIELD_LONGITUDE, FIELD_ALTITUDE)
|
||||
private val SUPPORTED_TYPES = listOf(
|
||||
"application/vnd.microg.location.cell+csv+gzip",
|
||||
"application/vnd.microg.location.cell+csv",
|
||||
"application/vnd.microg.location.wifi+csv+gzip",
|
||||
"application/vnd.microg.location.wifi+csv",
|
||||
"application/gzip",
|
||||
"application/x-gzip",
|
||||
"text/csv",
|
||||
)
|
||||
|
||||
private fun getCellSelectionArgs(cell: CellDetails): Array<String> {
|
||||
return arrayOf(
|
||||
cell.mcc.toString(),
|
||||
cell.mnc.toString(),
|
||||
cell.type.ordinal.toString(),
|
||||
(cell.lac ?: cell.tac ?: 0).toString(),
|
||||
cell.cid.toString(),
|
||||
(cell.pscOrPci ?: 0).toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun getWifiSelection(wifi: WifiDetails): String {
|
||||
return "$FIELD_MAC = x'${wifi.macBytes.toHexString()}'"
|
||||
}
|
||||
|
||||
private fun getCellPreSelectionArgs(cell: CellDetails): Array<String> {
|
||||
return arrayOf(
|
||||
cell.mcc.toString(),
|
||||
cell.mnc.toString()
|
||||
)
|
||||
}
|
||||
|
||||
private fun Cursor.getSingleLocation(maxAge: Long): Location? {
|
||||
return try {
|
||||
if (moveToNext()) {
|
||||
getLocation(maxAge)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Cursor.getLocation(maxAge: Long): Location? {
|
||||
if (getLong(5) > System.currentTimeMillis() - maxAge) {
|
||||
if (getDouble(2) == 0.0) return NEGATIVE_CACHE_ENTRY
|
||||
return Location(PROVIDER_CACHE).apply {
|
||||
latitude = getDouble(0)
|
||||
longitude = getDouble(1)
|
||||
accuracy = getDouble(2).toFloat()
|
||||
getDoubleOrNull(3)?.let { altitude = it }
|
||||
verticalAccuracy = getDoubleOrNull(4)?.toFloat()
|
||||
time = getLong(5)
|
||||
precision = getDouble(6)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Cursor.getMidLocation(
|
||||
maxAge: Long = Long.MAX_VALUE,
|
||||
baseAccuracy: Double = 0.0,
|
||||
accuracyFactor: Double = 0.0,
|
||||
baseVerticalAccuracy: Double = baseAccuracy,
|
||||
verticalAccuracyFactor: Double = accuracyFactor,
|
||||
basePrecision: Double = 0.0,
|
||||
precisionFactor: Double = 0.0
|
||||
): Location? {
|
||||
if (maxAge == Long.MAX_VALUE || getLong(7) > System.currentTimeMillis() - maxAge) {
|
||||
val high = Location(PROVIDER_CACHE).apply { latitude = getDouble(0); longitude = getDouble(2) }
|
||||
if (!isNull(4)) high.altitude = getDouble(4)
|
||||
val low = Location(PROVIDER_CACHE).apply { latitude = getDouble(1); longitude = getDouble(3) }
|
||||
if (!isNull(5)) low.altitude = getDouble(5)
|
||||
val count = getInt(6)
|
||||
val computedAccuracy = baseAccuracy / (1 + (accuracyFactor * (count - 1).toDouble()))
|
||||
val computedVerticalAccuracy = baseVerticalAccuracy / (1 + (verticalAccuracyFactor * (count - 1).toDouble()))
|
||||
return Location(PROVIDER_CACHE).apply {
|
||||
latitude = (high.latitude + low.latitude) / 2.0
|
||||
longitude = (high.longitude + low.longitude) / 2.0
|
||||
accuracy = max(high.distanceTo(low) / 2.0, computedAccuracy).toFloat()
|
||||
if (high.hasAltitude() && low.hasAltitude()) {
|
||||
altitude = (high.altitude + low.altitude) / 2.0
|
||||
verticalAccuracy = max((abs(high.altitude - low.altitude) / 2.0), computedVerticalAccuracy).toFloat()
|
||||
} else if (high.hasAltitude()) {
|
||||
altitude = high.altitude
|
||||
verticalAccuracy = computedVerticalAccuracy.toFloat()
|
||||
} else if (low.hasAltitude()) {
|
||||
altitude = low.altitude
|
||||
verticalAccuracy = computedVerticalAccuracy.toFloat()
|
||||
}
|
||||
precision = basePrecision * (1 + (precisionFactor * (count - 1)))
|
||||
highLocation = high
|
||||
lowLocation = low
|
||||
extras += EXTRA_RECORD_COUNT to count
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun Cursor.getWifiMidLocation() = getMidLocation(
|
||||
MAX_LEARN_AGE,
|
||||
baseAccuracy = LEARN_BASE_ACCURACY_WIFI,
|
||||
accuracyFactor = LEARN_ACCURACY_FACTOR_WIFI,
|
||||
baseVerticalAccuracy = LEARN_BASE_VERTICAL_ACCURACY_WIFI,
|
||||
verticalAccuracyFactor = LEARN_VERTICAL_ACCURACY_FACTOR_WIFI,
|
||||
basePrecision = LEARN_BASE_PRECISION_WIFI,
|
||||
precisionFactor = LEARN_PRECISION_FACTOR_WIFI
|
||||
)
|
||||
|
||||
private fun Cursor.getCellMidLocation() = getMidLocation(
|
||||
MAX_LEARN_AGE,
|
||||
baseAccuracy = LEARN_BASE_ACCURACY_CELL,
|
||||
accuracyFactor = LEARN_ACCURACY_FACTOR_CELL,
|
||||
baseVerticalAccuracy = LEARN_BASE_VERTICAL_ACCURACY_CELL,
|
||||
verticalAccuracyFactor = LEARN_VERTICAL_ACCURACY_FACTOR_CELL,
|
||||
basePrecision = LEARN_BASE_PRECISION_CELL,
|
||||
precisionFactor = LEARN_PRECISION_FACTOR_CELL
|
||||
)
|
||||
|
||||
private var Location.highLocation: Location?
|
||||
get() = extras?.let { BundleCompat.getParcelable(it, EXTRA_HIGH_LOCATION, Location::class.java) }
|
||||
set(value) { extras += EXTRA_HIGH_LOCATION to value }
|
||||
|
||||
private val Location.highLatitude: Double
|
||||
get() = highLocation?.latitude ?: latitude
|
||||
private val Location.highLongitude: Double
|
||||
get() = highLocation?.longitude ?: longitude
|
||||
private val Location.highAltitude: Double?
|
||||
get() = highLocation?.takeIf { it.hasAltitude() }?.altitude ?: altitude.takeIf { hasAltitude() }
|
||||
|
||||
private var Location.lowLocation: Location?
|
||||
get() = extras?.let { BundleCompat.getParcelable(it, EXTRA_LOW_LOCATION, Location::class.java) }
|
||||
set(value) { extras += EXTRA_LOW_LOCATION to value }
|
||||
|
||||
private val Location.lowLatitude: Double
|
||||
get() = lowLocation?.latitude ?: latitude
|
||||
private val Location.lowLongitude: Double
|
||||
get() = lowLocation?.longitude ?: longitude
|
||||
private val Location.lowAltitude: Double?
|
||||
get() = lowLocation?.takeIf { it.hasAltitude() }?.altitude ?: altitude.takeIf { hasAltitude() }
|
||||
|
||||
private var Location?.recordCount: Int
|
||||
get() = this?.extras?.getInt(EXTRA_RECORD_COUNT, 0) ?: 0
|
||||
set(value) { this?.extras += EXTRA_RECORD_COUNT to value }
|
||||
|
||||
private fun max(first: Double, second: Double?): Double {
|
||||
if (second == null) return first
|
||||
return max(first, second)
|
||||
}
|
||||
|
||||
private fun max(first: Long, second: Long?): Long {
|
||||
if (second == null) return first
|
||||
return max(first, second)
|
||||
}
|
||||
|
||||
private fun min(first: Double, second: Double?): Double {
|
||||
if (second == null) return first
|
||||
return min(first, second)
|
||||
}
|
||||
|
||||
private fun ContentValues.putLocation(location: Location) {
|
||||
if (location != NEGATIVE_CACHE_ENTRY) {
|
||||
put(FIELD_LATITUDE, location.latitude)
|
||||
put(FIELD_LONGITUDE, location.longitude)
|
||||
put(FIELD_ACCURACY, location.accuracy)
|
||||
put(FIELD_TIME, location.time)
|
||||
put(FIELD_PRECISION, location.precision)
|
||||
if (location.hasAltitude()) {
|
||||
put(FIELD_ALTITUDE, location.altitude)
|
||||
put(FIELD_ALTITUDE_ACCURACY, location.verticalAccuracy)
|
||||
}
|
||||
} else {
|
||||
put(FIELD_LATITUDE, 0.0)
|
||||
put(FIELD_LONGITUDE, 0.0)
|
||||
put(FIELD_ACCURACY, 0.0)
|
||||
put(FIELD_TIME, System.currentTimeMillis())
|
||||
put(FIELD_PRECISION, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ContentValues.putLearnLocation(location: Location, previous: Location? = null, badTime: Long? = null, import: Boolean = false) {
|
||||
if (location != NEGATIVE_CACHE_ENTRY) {
|
||||
put(FIELD_LATITUDE_HIGH, max(location.latitude, previous?.highLatitude))
|
||||
put(FIELD_LATITUDE_LOW, min(location.latitude, previous?.lowLatitude))
|
||||
put(FIELD_LONGITUDE_HIGH, max(location.longitude, previous?.highLongitude))
|
||||
put(FIELD_LONGITUDE_LOW, min(location.longitude, previous?.lowLongitude))
|
||||
put(FIELD_TIME, max(location.time, previous?.time ?: 0))
|
||||
if (location.hasAltitude()) {
|
||||
put(FIELD_ALTITUDE_HIGH, max(location.altitude, previous?.highAltitude))
|
||||
put(FIELD_ALTITUDE_LOW, min(location.altitude, previous?.lowAltitude))
|
||||
}
|
||||
put(FIELD_LEARN_RECORD_COUNT, previous.recordCount + 1)
|
||||
if (badTime != null && !import) {
|
||||
put(FIELD_BAD_TIME, max(badTime, previous?.time))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
interface NetworkDetails {
|
||||
val timestamp: Long?
|
||||
val signalStrength: Int?
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.os.SystemClock
|
||||
import android.os.WorkSource
|
||||
import org.microg.gms.location.EXTRA_LOCATION
|
||||
import org.microg.gms.location.EXTRA_ELAPSED_REALTIME
|
||||
|
||||
class NetworkLocationRequest(
|
||||
var pendingIntent: PendingIntent,
|
||||
var intervalMillis: Long,
|
||||
var lowPower: Boolean,
|
||||
var bypass: Boolean,
|
||||
var workSource: WorkSource
|
||||
) {
|
||||
var lastRealtime = 0L
|
||||
private set
|
||||
|
||||
fun send(context: Context, location: Location) {
|
||||
lastRealtime = SystemClock.elapsedRealtime()
|
||||
pendingIntent.send(context, 0, Intent().apply {
|
||||
putExtra(EXTRA_LOCATION, location)
|
||||
putExtra(EXTRA_ELAPSED_REALTIME, lastRealtime)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,619 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.net.Uri
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.*
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.util.Log
|
||||
import androidx.annotation.GuardedBy
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.location.LocationListenerCompat
|
||||
import androidx.core.location.LocationManagerCompat
|
||||
import androidx.core.location.LocationRequestCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.microg.gms.location.*
|
||||
import org.microg.gms.location.network.cell.CellDetails
|
||||
import org.microg.gms.location.network.cell.CellDetailsCallback
|
||||
import org.microg.gms.location.network.cell.CellDetailsSource
|
||||
import org.microg.gms.location.network.ichnaea.IchnaeaServiceClient
|
||||
import org.microg.gms.location.network.wifi.*
|
||||
import java.io.FileDescriptor
|
||||
import java.io.PrintWriter
|
||||
import java.util.*
|
||||
import kotlin.math.*
|
||||
|
||||
class NetworkLocationService : LifecycleService(), WifiDetailsCallback, CellDetailsCallback {
|
||||
private lateinit var handlerThread: HandlerThread
|
||||
private lateinit var handler: Handler
|
||||
|
||||
@GuardedBy("activeRequests")
|
||||
private val activeRequests = HashSet<NetworkLocationRequest>()
|
||||
private val highPowerScanRunnable = Runnable { this.scan(false) }
|
||||
private val lowPowerScanRunnable = Runnable { this.scan(true) }
|
||||
private var wifiDetailsSource: WifiDetailsSource? = null
|
||||
private var cellDetailsSource: CellDetailsSource? = null
|
||||
private val ichnaea by lazy { IchnaeaServiceClient(this) }
|
||||
private val database by lazy { LocationDatabase(this) }
|
||||
private val movingWifiHelper by lazy { MovingWifiHelper(this) }
|
||||
private val settings by lazy { LocationSettings(this) }
|
||||
|
||||
private var lastHighPowerScanRealtime = 0L
|
||||
private var lastLowPowerScanRealtime = 0L
|
||||
private var highPowerIntervalMillis = Long.MAX_VALUE
|
||||
private var lowPowerIntervalMillis = Long.MAX_VALUE
|
||||
|
||||
private var lastWifiDetailsRealtimeMillis = 0L
|
||||
private var lastCellDetailsRealtimeMillis = 0L
|
||||
|
||||
private val locationLock = Any()
|
||||
private var lastWifiLocation: Location? = null
|
||||
private var lastCellLocation: Location? = null
|
||||
private var lastLocation: Location? = null
|
||||
|
||||
private val passiveLocationListener by lazy { LocationListenerCompat { onNewPassiveLocation(it) } }
|
||||
|
||||
@GuardedBy("gpsLocationBuffer")
|
||||
private val gpsLocationBuffer = LinkedList<Location>()
|
||||
private var passiveListenerActive = false
|
||||
|
||||
private var currentLocalMovingWifi: WifiDetails? = null
|
||||
private var lastLocalMovingWifiLocationCandidate: Location? = null
|
||||
|
||||
private val interval: Long
|
||||
get() = min(highPowerIntervalMillis, lowPowerIntervalMillis)
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
handlerThread = HandlerThread(NetworkLocationService::class.java.simpleName)
|
||||
handlerThread.start()
|
||||
handler = Handler(handlerThread.looper)
|
||||
wifiDetailsSource = WifiDetailsSource.create(this, this).apply { enable() }
|
||||
cellDetailsSource = CellDetailsSource.create(this, this).apply { enable() }
|
||||
if (settings.effectiveEndpoint == null && (settings.wifiIchnaea || settings.cellIchnaea)) {
|
||||
sendBroadcast(Intent(ACTION_CONFIGURATION_REQUIRED).apply {
|
||||
`package` = packageName
|
||||
putExtra(EXTRA_CONFIGURATION, CONFIGURATION_FIELD_ONLINE_SOURCE)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePassiveGpsListenerRegistration() {
|
||||
try {
|
||||
getSystemService<LocationManager>()?.let { locationManager ->
|
||||
if ((settings.cellLearning || settings.wifiLearning) && (highPowerIntervalMillis != Long.MAX_VALUE)) {
|
||||
if (!passiveListenerActive) {
|
||||
LocationManagerCompat.requestLocationUpdates(
|
||||
locationManager,
|
||||
LocationManager.PASSIVE_PROVIDER,
|
||||
LocationRequestCompat.Builder(LocationRequestCompat.PASSIVE_INTERVAL)
|
||||
.setQuality(LocationRequestCompat.QUALITY_LOW_POWER)
|
||||
.setMinUpdateIntervalMillis(GPS_PASSIVE_INTERVAL)
|
||||
.build(),
|
||||
passiveLocationListener,
|
||||
handlerThread.looper
|
||||
)
|
||||
passiveListenerActive = true
|
||||
}
|
||||
} else {
|
||||
if (passiveListenerActive) {
|
||||
LocationManagerCompat.removeUpdates(locationManager, passiveLocationListener)
|
||||
passiveListenerActive = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Log.d(TAG, "GPS location retriever not initialized due to lack of permission")
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, "GPS location retriever not initialized", e)
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
private fun scan(lowPower: Boolean) {
|
||||
if (!lowPower) lastHighPowerScanRealtime = SystemClock.elapsedRealtime()
|
||||
lastLowPowerScanRealtime = SystemClock.elapsedRealtime()
|
||||
val currentLocalMovingWifi = currentLocalMovingWifi
|
||||
val lastWifiScanIsSufficientlyNewForMoving = lastWifiDetailsRealtimeMillis > SystemClock.elapsedRealtime() - MAX_LOCAL_WIFI_SCAN_AGE_MS
|
||||
val movingWifiWasProducingRecentResults = (lastLocalMovingWifiLocationCandidate?.elapsedMillis ?: 0L) > SystemClock.elapsedRealtime() - max(MAX_LOCAL_WIFI_AGE_MS, interval * 2)
|
||||
val movingWifiLocationWasAccurate = (lastLocalMovingWifiLocationCandidate?.accuracy ?: Float.MAX_VALUE) <= MOVING_WIFI_HIGH_POWER_ACCURACY
|
||||
if (currentLocalMovingWifi != null &&
|
||||
movingWifiWasProducingRecentResults &&
|
||||
lastWifiScanIsSufficientlyNewForMoving &&
|
||||
(movingWifiLocationWasAccurate || lowPower) &&
|
||||
getSystemService<WifiManager>()?.connectionInfo?.bssid == currentLocalMovingWifi.macAddress
|
||||
) {
|
||||
Log.d(TAG, "Skip network scan and use current local wifi instead. low=$lowPower accurate=$movingWifiLocationWasAccurate")
|
||||
onWifiDetailsAvailable(listOf(currentLocalMovingWifi.copy(timestamp = System.currentTimeMillis())))
|
||||
} else {
|
||||
val workSource = synchronized(activeRequests) { activeRequests.minByOrNull { it.intervalMillis }?.workSource }
|
||||
Log.d(TAG, "Start network scan for $workSource")
|
||||
if (settings.wifiLearning || settings.wifiCaching || settings.wifiIchnaea) {
|
||||
wifiDetailsSource?.startScan(workSource)
|
||||
} else if (settings.wifiMoving) {
|
||||
// No need to scan if only moving wifi enabled, instead simulate scan based on current connection info
|
||||
val connectionInfo = getSystemService<WifiManager>()?.connectionInfo
|
||||
if (SDK_INT >= 31 && connectionInfo != null) {
|
||||
onWifiDetailsAvailable(listOf(connectionInfo.toWifiDetails()))
|
||||
} else if (currentLocalMovingWifi != null && connectionInfo?.bssid == currentLocalMovingWifi.macAddress) {
|
||||
onWifiDetailsAvailable(listOf(currentLocalMovingWifi.copy(timestamp = System.currentTimeMillis())))
|
||||
} else {
|
||||
// Can't simulate scan, so just scan
|
||||
wifiDetailsSource?.startScan(workSource)
|
||||
}
|
||||
}
|
||||
if (settings.cellLearning || settings.cellCaching || settings.cellIchnaea) {
|
||||
cellDetailsSource?.startScan(workSource)
|
||||
}
|
||||
}
|
||||
updateRequests()
|
||||
}
|
||||
|
||||
private fun updateRequests(forceNow: Boolean = false, lowPower: Boolean = true) {
|
||||
synchronized(activeRequests) {
|
||||
lowPowerIntervalMillis = Long.MAX_VALUE
|
||||
highPowerIntervalMillis = Long.MAX_VALUE
|
||||
for (request in activeRequests) {
|
||||
if (request.lowPower) lowPowerIntervalMillis = min(lowPowerIntervalMillis, request.intervalMillis)
|
||||
else highPowerIntervalMillis = min(highPowerIntervalMillis, request.intervalMillis)
|
||||
}
|
||||
}
|
||||
|
||||
// Low power must be strictly less than high power
|
||||
if (highPowerIntervalMillis <= lowPowerIntervalMillis) lowPowerIntervalMillis = Long.MAX_VALUE
|
||||
|
||||
val nextHighPowerRequestIn =
|
||||
if (highPowerIntervalMillis == Long.MAX_VALUE) Long.MAX_VALUE else highPowerIntervalMillis - (SystemClock.elapsedRealtime() - lastHighPowerScanRealtime)
|
||||
val nextLowPowerRequestIn =
|
||||
if (lowPowerIntervalMillis == Long.MAX_VALUE) Long.MAX_VALUE else lowPowerIntervalMillis - (SystemClock.elapsedRealtime() - lastLowPowerScanRealtime)
|
||||
|
||||
handler.removeCallbacks(highPowerScanRunnable)
|
||||
handler.removeCallbacks(lowPowerScanRunnable)
|
||||
if ((forceNow && !lowPower) || nextHighPowerRequestIn <= 0) {
|
||||
Log.d(TAG, "Schedule high-power scan now")
|
||||
handler.post(highPowerScanRunnable)
|
||||
} else if (forceNow || nextLowPowerRequestIn <= 0) {
|
||||
Log.d(TAG, "Schedule low-power scan now")
|
||||
handler.post(lowPowerScanRunnable)
|
||||
} else {
|
||||
// Reschedule next request
|
||||
if (nextLowPowerRequestIn < nextHighPowerRequestIn) {
|
||||
Log.d(TAG, "Schedule low-power scan in ${nextLowPowerRequestIn}ms")
|
||||
handler.postDelayed(lowPowerScanRunnable, nextLowPowerRequestIn)
|
||||
} else if (nextHighPowerRequestIn != Long.MAX_VALUE) {
|
||||
Log.d(TAG, "Schedule high-power scan in ${nextHighPowerRequestIn}ms")
|
||||
handler.postDelayed(highPowerScanRunnable, nextHighPowerRequestIn)
|
||||
}
|
||||
}
|
||||
|
||||
updatePassiveGpsListenerRegistration()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_NETWORK_LOCATION_SERVICE) {
|
||||
handler.post {
|
||||
val pendingIntent = intent.getParcelableExtra<PendingIntent>(EXTRA_PENDING_INTENT) ?: return@post
|
||||
val enable = intent.getBooleanExtra(EXTRA_ENABLE, false)
|
||||
if (enable) {
|
||||
val intervalMillis = intent.getLongExtra(EXTRA_INTERVAL_MILLIS, -1L)
|
||||
if (intervalMillis < 0) return@post
|
||||
var forceNow = intent.getBooleanExtra(EXTRA_FORCE_NOW, false)
|
||||
val lowPower = intent.getBooleanExtra(EXTRA_LOW_POWER, true)
|
||||
val bypass = intent.getBooleanExtra(EXTRA_BYPASS, false)
|
||||
val workSource = intent.getParcelableExtra(EXTRA_WORK_SOURCE) ?: WorkSource()
|
||||
synchronized(activeRequests) {
|
||||
if (activeRequests.any { it.pendingIntent == pendingIntent }) {
|
||||
forceNow = false
|
||||
activeRequests.removeAll { it.pendingIntent == pendingIntent }
|
||||
}
|
||||
activeRequests.add(NetworkLocationRequest(pendingIntent, intervalMillis, lowPower, bypass, workSource))
|
||||
}
|
||||
handler.post { updateRequests(forceNow, lowPower) }
|
||||
} else {
|
||||
synchronized(activeRequests) {
|
||||
activeRequests.removeAll { it.pendingIntent == pendingIntent }
|
||||
}
|
||||
handler.post { updateRequests() }
|
||||
}
|
||||
}
|
||||
} else if (intent?.action == ACTION_NETWORK_IMPORT_EXPORT) {
|
||||
handler.post {
|
||||
val callback = intent.getParcelableExtra<Messenger>(EXTRA_MESSENGER)
|
||||
val replyWhat = intent.getIntExtra(EXTRA_REPLY_WHAT, 0)
|
||||
when (intent.getStringExtra(EXTRA_DIRECTION)) {
|
||||
DIRECTION_EXPORT -> {
|
||||
val name = intent.getStringExtra(EXTRA_NAME)
|
||||
val uri = name?.let { database.exportLearned(it) }
|
||||
callback?.send(Message.obtain().apply {
|
||||
what = replyWhat
|
||||
data = bundleOf(
|
||||
EXTRA_DIRECTION to DIRECTION_EXPORT,
|
||||
EXTRA_NAME to name,
|
||||
EXTRA_URI to uri,
|
||||
)
|
||||
})
|
||||
}
|
||||
DIRECTION_IMPORT -> {
|
||||
val uri = intent.getParcelableExtra<Uri>(EXTRA_URI)
|
||||
val counter = uri?.let { database.importLearned(it) } ?: 0
|
||||
callback?.send(Message.obtain().apply {
|
||||
what = replyWhat
|
||||
arg1 = counter
|
||||
data = bundleOf(
|
||||
EXTRA_DIRECTION to DIRECTION_IMPORT,
|
||||
EXTRA_URI to uri,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super.onStartCommand(intent, flags, startId)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handlerThread.stop()
|
||||
wifiDetailsSource?.disable()
|
||||
wifiDetailsSource = null
|
||||
cellDetailsSource?.disable()
|
||||
cellDetailsSource = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
fun Location.mayTakeAltitude(location: Location?) {
|
||||
if (location != null && !hasAltitude() && location.hasAltitude()) {
|
||||
altitude = location.altitude
|
||||
verticalAccuracy = location.verticalAccuracy
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun queryWifiLocation(wifis: List<WifiDetails>): Location? {
|
||||
var candidate: Location? = queryCurrentLocalMovingWifiLocation()
|
||||
if ((candidate?.accuracy ?: Float.MAX_VALUE) <= 50f) return candidate
|
||||
val databaseCandidate = queryWifiLocationFromDatabase(wifis)
|
||||
if (databaseCandidate != null && (candidate == null || databaseCandidate.precision > candidate.precision)) {
|
||||
databaseCandidate.mayTakeAltitude(candidate)
|
||||
candidate = databaseCandidate
|
||||
}
|
||||
if ((candidate?.accuracy ?: Float.MAX_VALUE) <= 50f && (candidate?.precision ?: 0.0) > 1.0) return candidate
|
||||
val ichnaeaCandidate = queryIchnaeaWifiLocation(wifis, minimumPrecision = (candidate?.precision ?: 0.0))
|
||||
if (ichnaeaCandidate != null && ichnaeaCandidate.accuracy >= (candidate?.accuracy ?: 0f)) {
|
||||
ichnaeaCandidate.mayTakeAltitude(candidate)
|
||||
candidate = ichnaeaCandidate
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private suspend fun queryCurrentLocalMovingWifiLocation(): Location? {
|
||||
var candidate: Location? = null
|
||||
val currentLocalMovingWifi = currentLocalMovingWifi
|
||||
if (currentLocalMovingWifi != null && settings.wifiMoving) {
|
||||
try {
|
||||
withTimeout(5000L) {
|
||||
lastLocalMovingWifiLocationCandidate = movingWifiHelper.retrieveMovingLocation(currentLocalMovingWifi)
|
||||
}
|
||||
candidate = lastLocalMovingWifiLocationCandidate
|
||||
} catch (e: Exception) {
|
||||
lastLocalMovingWifiLocationCandidate = null
|
||||
Log.w(TAG, "Failed retrieving location for current moving wifi ${currentLocalMovingWifi.ssid}", e)
|
||||
}
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private suspend fun queryWifiLocationFromDatabase(wifis: List<WifiDetails>): Location? =
|
||||
queryLocationFromRetriever(wifis, 1000.0) { database.getWifiLocation(it, settings.wifiLearning) }
|
||||
|
||||
private suspend fun queryCellLocationFromDatabase(cells: List<CellDetails>): Location? =
|
||||
queryLocationFromRetriever(cells, 50000.0) { it.location ?: database.getCellLocation(it, settings.cellLearning) }
|
||||
|
||||
private val NetworkDetails.signalStrengthBounded: Int
|
||||
get() = (signalStrength ?: -100).coerceIn(-100, -10)
|
||||
|
||||
private val NetworkDetails.ageBounded: Long
|
||||
get() = (System.currentTimeMillis() - (timestamp ?: 0)).coerceIn(0, 60000)
|
||||
|
||||
private val NetworkDetails.weight: Double
|
||||
get() = min(1.0, sqrt(2000.0 / ageBounded)) / signalStrengthBounded.toDouble().pow(2)
|
||||
|
||||
private fun <T: NetworkDetails> queryLocationFromRetriever(data: List<T>, maxClusterDistance: Double = 0.0, retriever: (T) -> Location?): Location? {
|
||||
val locations = data.mapNotNull { detail -> retriever(detail)?.takeIf { it != NEGATIVE_CACHE_ENTRY }?.let { detail to it } }
|
||||
if (locations.isNotEmpty()) {
|
||||
val clusters = locations.map { mutableListOf(it) }
|
||||
for (cellLocation in locations) {
|
||||
for (cluster in clusters) {
|
||||
if (cluster.first() == cellLocation) continue;
|
||||
if (cluster.first().second.distanceTo(cellLocation.second) < max(cluster.first().second.accuracy * 2.0, maxClusterDistance)) {
|
||||
cluster.add(cellLocation)
|
||||
}
|
||||
}
|
||||
}
|
||||
val cluster = clusters.maxBy { it.sumOf { it.second.precision } }
|
||||
|
||||
return Location(PROVIDER_CACHE).apply {
|
||||
latitude = cluster.weightedAverage { it.second.latitude to it.first.weight }
|
||||
longitude = cluster.weightedAverage { it.second.longitude to it.first.weight }
|
||||
accuracy = min(
|
||||
cluster.map { it.second.distanceTo(this) + it.second.accuracy }.average().toFloat() / cluster.size,
|
||||
cluster.minOf { it.second.accuracy }
|
||||
)
|
||||
val altitudeCluster = cluster.filter { it.second.hasAltitude() }.takeIf { it.isNotEmpty() }
|
||||
if (altitudeCluster != null) {
|
||||
altitude = altitudeCluster.weightedAverage { it.second.altitude to it.first.weight }
|
||||
verticalAccuracy = min(
|
||||
altitudeCluster.map { abs(altitude - it.second.altitude) + (it.second.verticalAccuracy ?: it.second.accuracy) }.average().toFloat() / cluster.size,
|
||||
altitudeCluster.minOf { it.second.verticalAccuracy ?: it.second.accuracy }
|
||||
)
|
||||
}
|
||||
precision = cluster.sumOf { it.second.precision }
|
||||
time = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun queryIchnaeaWifiLocation(wifis: List<WifiDetails>, minimumPrecision: Double = 0.0): Location? {
|
||||
if (settings.wifiIchnaea && wifis.size >= 3 && wifis.size / IchnaeaServiceClient.WIFI_BASE_PRECISION_COUNT >= minimumPrecision) {
|
||||
try {
|
||||
val ichnaeaCandidate = ichnaea.retrieveMultiWifiLocation(wifis) { wifi, location ->
|
||||
if (settings.wifiCaching) database.putWifiLocation(wifi, location)
|
||||
}!!
|
||||
ichnaeaCandidate.time = System.currentTimeMillis()
|
||||
return ichnaeaCandidate
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed retrieving location for ${wifis.size} wifi networks", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun <T> List<T>.weightedAverage(f: (T) -> Pair<Double, Double>): Double {
|
||||
val valuesAndWeights = map { f(it) }
|
||||
return valuesAndWeights.sumOf { it.first * it.second } / valuesAndWeights.sumOf { it.second }
|
||||
}
|
||||
|
||||
override fun onWifiDetailsAvailable(wifis: List<WifiDetails>) {
|
||||
if (wifis.isEmpty()) return
|
||||
val scanResultTimestamp = min(wifis.maxOf { it.timestamp ?: Long.MAX_VALUE }, System.currentTimeMillis())
|
||||
val scanResultRealtimeMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - scanResultTimestamp)
|
||||
@Suppress("DEPRECATION")
|
||||
currentLocalMovingWifi = getSystemService<WifiManager>()?.connectionInfo
|
||||
?.let { wifiInfo -> wifis.filter { it.macAddress == wifiInfo.bssid && it.isMoving } }
|
||||
?.filter { movingWifiHelper.isLocallyRetrievable(it) }
|
||||
?.singleOrNull()
|
||||
val requestableWifis = wifis.filter(WifiDetails::isRequestable)
|
||||
if (requestableWifis.isEmpty() && currentLocalMovingWifi == null) return
|
||||
updateWifiLocation(requestableWifis, scanResultRealtimeMillis, scanResultTimestamp)
|
||||
}
|
||||
|
||||
private fun updateWifiLocation(requestableWifis: List<WifiDetails>, scanResultRealtimeMillis: Long = 0, scanResultTimestamp: Long = 0) {
|
||||
if (settings.wifiLearning) {
|
||||
for (wifi in requestableWifis.filter { it.timestamp != null }) {
|
||||
val wifiElapsedMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - wifi.timestamp!!)
|
||||
getGpsLocation(wifiElapsedMillis)?.let {
|
||||
database.learnWifiLocation(wifi, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scanResultRealtimeMillis < lastWifiDetailsRealtimeMillis + interval / 2 && lastWifiDetailsRealtimeMillis != 0L && scanResultRealtimeMillis != 0L) {
|
||||
Log.d(TAG, "Ignoring wifi details, similar age as last ($scanResultRealtimeMillis < $lastWifiDetailsRealtimeMillis + $interval / 2)")
|
||||
return
|
||||
}
|
||||
val previousLastRealtimeMillis = lastWifiDetailsRealtimeMillis
|
||||
if (scanResultRealtimeMillis != 0L) lastWifiDetailsRealtimeMillis = scanResultRealtimeMillis
|
||||
lifecycleScope.launch {
|
||||
val location = queryWifiLocation(requestableWifis)
|
||||
if (location == null) {
|
||||
lastWifiDetailsRealtimeMillis = previousLastRealtimeMillis
|
||||
return@launch
|
||||
}
|
||||
if (scanResultTimestamp != 0L) location.time = max(scanResultTimestamp, location.time)
|
||||
if (scanResultRealtimeMillis != 0L) location.elapsedRealtimeNanos = max(location.elapsedRealtimeNanos, scanResultRealtimeMillis * 1_000_000L)
|
||||
synchronized(locationLock) {
|
||||
lastWifiLocation = location
|
||||
}
|
||||
sendLocationUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWifiSourceFailed() {
|
||||
// Wifi source failed, create a new one
|
||||
wifiDetailsSource?.disable()
|
||||
wifiDetailsSource = WifiDetailsSource.create(this, this).apply { enable() }
|
||||
}
|
||||
|
||||
private suspend fun queryCellLocation(cells: List<CellDetails>): Location? {
|
||||
val candidate = queryCellLocationFromDatabase(cells)
|
||||
if ((candidate?.precision ?: 0.0) > 1.0) return candidate
|
||||
val cellsToUpdate = cells.filter { it.location == null && database.getCellLocation(it, settings.cellLearning) == null }
|
||||
for (cell in cellsToUpdate) {
|
||||
queryIchnaeaCellLocation(cell)
|
||||
}
|
||||
// Try again after fetching records from internet
|
||||
return queryCellLocationFromDatabase(cells)
|
||||
}
|
||||
|
||||
private suspend fun queryIchnaeaCellLocation(cell: CellDetails): Location? {
|
||||
if (settings.cellIchnaea) {
|
||||
try {
|
||||
val ichnaeaCandidate = ichnaea.retrieveSingleCellLocation(cell) { cell, location ->
|
||||
if (settings.cellCaching) database.putCellLocation(cell, location)
|
||||
} ?: NEGATIVE_CACHE_ENTRY
|
||||
if (settings.cellCaching) {
|
||||
if (ichnaeaCandidate == NEGATIVE_CACHE_ENTRY) {
|
||||
database.putCellLocation(cell, NEGATIVE_CACHE_ENTRY)
|
||||
return null
|
||||
} else {
|
||||
ichnaeaCandidate.time = System.currentTimeMillis()
|
||||
database.putCellLocation(cell, ichnaeaCandidate)
|
||||
return ichnaeaCandidate
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed retrieving location for cell network", e)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun onCellDetailsAvailable(cells: List<CellDetails>) {
|
||||
if (settings.cellLearning) {
|
||||
for (cell in cells.filter { it.timestamp != null && it.location == null }) {
|
||||
val cellElapsedMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - cell.timestamp!!)
|
||||
getGpsLocation(cellElapsedMillis)?.let {
|
||||
database.learnCellLocation(cell, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
val scanResultTimestamp = min(cells.maxOf { it.timestamp ?: Long.MAX_VALUE }, System.currentTimeMillis())
|
||||
val scanResultRealtimeMillis = SystemClock.elapsedRealtime() - (System.currentTimeMillis() - scanResultTimestamp)
|
||||
if (scanResultRealtimeMillis < lastCellDetailsRealtimeMillis + interval / 2 && lastCellDetailsRealtimeMillis != 0L) {
|
||||
Log.d(TAG, "Ignoring cell details, similar age as last ($scanResultRealtimeMillis < $lastCellDetailsRealtimeMillis + $interval / 2)")
|
||||
return
|
||||
}
|
||||
val previousLastRealtimeMillis = lastWifiDetailsRealtimeMillis
|
||||
lastCellDetailsRealtimeMillis = scanResultRealtimeMillis
|
||||
lifecycleScope.launch {
|
||||
val location = queryCellLocation(cells)
|
||||
if (location == null) {
|
||||
lastCellDetailsRealtimeMillis = previousLastRealtimeMillis
|
||||
return@launch
|
||||
}
|
||||
if (scanResultTimestamp != 0L) location.time = max(scanResultTimestamp, location.time)
|
||||
if (scanResultRealtimeMillis != 0L) location.elapsedRealtimeNanos = max(location.elapsedRealtimeNanos, scanResultRealtimeMillis * 1_000_000L)
|
||||
synchronized(locationLock) {
|
||||
lastCellLocation = location
|
||||
}
|
||||
sendLocationUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendLocationUpdate(now: Boolean = false) {
|
||||
fun cliffLocations(old: Location?, new: Location?): Location? {
|
||||
// We move from wifi towards cell with accuracy
|
||||
if (old == null) return new
|
||||
if (new == null) return old
|
||||
val diff = new.elapsedMillis - old.elapsedMillis
|
||||
if (diff < LOCATION_TIME_CLIFF_START_MS) return old
|
||||
if (diff > LOCATION_TIME_CLIFF_END_MS) return new
|
||||
val pct = (diff - LOCATION_TIME_CLIFF_START_MS).toDouble() / (LOCATION_TIME_CLIFF_END_MS - LOCATION_TIME_CLIFF_START_MS).toDouble()
|
||||
return Location(old).apply {
|
||||
provider = "cliff"
|
||||
latitude = old.latitude * (1.0-pct) + new.latitude * pct
|
||||
longitude = old.longitude * (1.0-pct) + new.longitude * pct
|
||||
accuracy = (old.accuracy * (1.0-pct) + new.accuracy * pct).toFloat()
|
||||
altitude = old.altitude * (1.0-pct) + new.altitude * pct
|
||||
time = (old.time.toDouble() * (1.0-pct) + new.time.toDouble() * pct).toLong()
|
||||
elapsedRealtimeNanos = (old.elapsedRealtimeNanos.toDouble() * (1.0-pct) + new.elapsedRealtimeNanos.toDouble() * pct).toLong()
|
||||
}
|
||||
}
|
||||
val location = synchronized(locationLock) {
|
||||
if (lastCellLocation == null && lastWifiLocation == null) return
|
||||
when {
|
||||
// Only non-null
|
||||
lastCellLocation == null -> lastWifiLocation
|
||||
lastWifiLocation == null -> lastCellLocation
|
||||
// Consider cliff end
|
||||
lastCellLocation!!.elapsedMillis > lastWifiLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_END_MS -> lastCellLocation
|
||||
lastWifiLocation!!.elapsedMillis > lastCellLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_START_MS -> lastWifiLocation
|
||||
// Wifi out of cell range with higher precision
|
||||
lastCellLocation!!.precision > lastWifiLocation!!.precision && lastWifiLocation!!.distanceTo(lastCellLocation!!) > 2 * lastCellLocation!!.accuracy -> lastCellLocation
|
||||
// Consider cliff start
|
||||
lastCellLocation!!.elapsedMillis > lastWifiLocation!!.elapsedMillis + LOCATION_TIME_CLIFF_START_MS -> cliffLocations(lastWifiLocation, lastCellLocation)
|
||||
else -> lastWifiLocation
|
||||
}
|
||||
} ?: return
|
||||
if (location == lastLocation) return
|
||||
if (lastLocation == lastWifiLocation && lastLocation.let { it != null && location.accuracy > it.accuracy } && !now) {
|
||||
Log.d(TAG, "Debounce inaccurate location update")
|
||||
handler.postDelayed({
|
||||
sendLocationUpdate(true)
|
||||
}, DEBOUNCE_DELAY_MS)
|
||||
return
|
||||
}
|
||||
lastLocation = location
|
||||
synchronized(activeRequests) {
|
||||
for (request in activeRequests.toList()) {
|
||||
try {
|
||||
request.send(this@NetworkLocationService, location)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Pending intent error $request")
|
||||
activeRequests.remove(request)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onNewPassiveLocation(location: Location) {
|
||||
if (location.provider != LocationManager.GPS_PROVIDER || location.accuracy > GPS_PASSIVE_MIN_ACCURACY) return
|
||||
synchronized(gpsLocationBuffer) {
|
||||
if (gpsLocationBuffer.isNotEmpty() && gpsLocationBuffer.last.elapsedMillis < SystemClock.elapsedRealtime() - GPS_BUFFER_SIZE * GPS_PASSIVE_INTERVAL) {
|
||||
gpsLocationBuffer.clear()
|
||||
} else if (gpsLocationBuffer.size >= GPS_BUFFER_SIZE) {
|
||||
gpsLocationBuffer.remove()
|
||||
}
|
||||
gpsLocationBuffer.offer(location)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getGpsLocation(elapsedMillis: Long): Location? {
|
||||
if (elapsedMillis + GPS_BUFFER_SIZE * GPS_PASSIVE_INTERVAL < SystemClock.elapsedRealtime()) return null
|
||||
synchronized(gpsLocationBuffer) {
|
||||
if (gpsLocationBuffer.isEmpty()) return null
|
||||
for (location in gpsLocationBuffer.descendingIterator()) {
|
||||
if (location.elapsedMillis in (elapsedMillis - GPS_PASSIVE_INTERVAL)..(elapsedMillis + GPS_PASSIVE_INTERVAL)) return location
|
||||
if (location.elapsedMillis < elapsedMillis) return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun dump(fd: FileDescriptor?, writer: PrintWriter, args: Array<out String>?) {
|
||||
writer.println("Last scan elapsed realtime: high-power: ${lastHighPowerScanRealtime.formatRealtime()}, low-power: ${lastLowPowerScanRealtime.formatRealtime()}")
|
||||
writer.println("Last scan result time: wifi: ${lastWifiDetailsRealtimeMillis.formatRealtime()}, cells: ${lastCellDetailsRealtimeMillis.formatRealtime()}")
|
||||
writer.println("Interval: high-power: ${highPowerIntervalMillis.formatDuration()}, low-power: ${lowPowerIntervalMillis.formatDuration()}")
|
||||
writer.println("Last wifi location: $lastWifiLocation${if (lastWifiLocation == lastLocation) " (active)" else ""}")
|
||||
writer.println("Last cell location: $lastCellLocation${if (lastCellLocation == lastLocation) " (active)" else ""}")
|
||||
writer.println("Wifi settings: ichnaea=${settings.wifiIchnaea} moving=${settings.wifiMoving} learn=${settings.wifiLearning}")
|
||||
writer.println("Cell settings: ichnaea=${settings.cellIchnaea} learn=${settings.cellLearning}")
|
||||
writer.println("Ichnaea settings: source=${settings.onlineSource?.id} endpoint=${settings.effectiveEndpoint} contribute=${settings.ichnaeaContribute}")
|
||||
ichnaea.dump(writer)
|
||||
writer.println("GPS location buffer size=${gpsLocationBuffer.size} first=${gpsLocationBuffer.firstOrNull()?.elapsedMillis?.formatRealtime()} last=${gpsLocationBuffer.lastOrNull()?.elapsedMillis?.formatRealtime()}")
|
||||
database.dump(writer)
|
||||
synchronized(activeRequests) {
|
||||
if (activeRequests.isNotEmpty()) {
|
||||
writer.println("Active requests:")
|
||||
for (request in activeRequests) {
|
||||
writer.println("- ${request.workSource} ${request.intervalMillis.formatDuration()} (low power: ${request.lowPower}, bypass: ${request.bypass}) reported ${request.lastRealtime.formatRealtime()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val GPS_BUFFER_SIZE = 60
|
||||
const val GPS_PASSIVE_INTERVAL = 1000L
|
||||
const val GPS_PASSIVE_MIN_ACCURACY = 25f
|
||||
const val LOCATION_TIME_CLIFF_START_MS = 30000L
|
||||
const val LOCATION_TIME_CLIFF_END_MS = 60000L
|
||||
const val DEBOUNCE_DELAY_MS = 5000L
|
||||
const val MAX_WIFI_SCAN_CACHE_AGE = 1000L * 60 * 60 * 24 // 1 day
|
||||
const val MAX_LOCAL_WIFI_AGE_MS = 60_000_000L // 1 minute
|
||||
const val MAX_LOCAL_WIFI_SCAN_AGE_MS = 600_000_000L // 10 minutes
|
||||
const val MOVING_WIFI_HIGH_POWER_ACCURACY = 100f
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.cell
|
||||
|
||||
import android.location.Location
|
||||
import org.microg.gms.location.network.NetworkDetails
|
||||
|
||||
data class CellDetails(
|
||||
val type: Type,
|
||||
val mcc: Int? = null,
|
||||
val mnc: Int? = null,
|
||||
val lac: Int? = null,
|
||||
val tac: Int? = null,
|
||||
val cid: Long? = null,
|
||||
val sid: Int? = null,
|
||||
val nid: Int? = null,
|
||||
val bsid: Int? = null,
|
||||
val pscOrPci: Int? = null,
|
||||
override val timestamp: Long? = null,
|
||||
override val signalStrength: Int? = null,
|
||||
val location: Location? = null
|
||||
) : NetworkDetails {
|
||||
companion object {
|
||||
enum class Type {
|
||||
CDMA, GSM, WCDMA, LTE, TDSCDMA, NR
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.cell
|
||||
|
||||
interface CellDetailsCallback {
|
||||
fun onCellDetailsAvailable(cells: List<CellDetails>)
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.cell
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.WorkSource
|
||||
import android.telephony.CellInfo
|
||||
import android.telephony.TelephonyManager
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
private const val TAG = "CellDetailsSource"
|
||||
|
||||
class CellDetailsSource(private val context: Context, private val callback: CellDetailsCallback) {
|
||||
fun enable() = Unit
|
||||
fun disable() = Unit
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
fun startScan(workSource: WorkSource?) {
|
||||
val telephonyManager = context.getSystemService<TelephonyManager>() ?: return
|
||||
if (SDK_INT >= 29) {
|
||||
try {
|
||||
telephonyManager.requestCellInfoUpdate(context.mainExecutor, object : TelephonyManager.CellInfoCallback() {
|
||||
override fun onCellInfo(cells: MutableList<CellInfo>) {
|
||||
val details = cells.map(CellInfo::toCellDetails).map { it.repair(context) }.filter(CellDetails::isValid)
|
||||
if (details.isNotEmpty()) callback.onCellDetailsAvailable(details)
|
||||
}
|
||||
})
|
||||
} catch (e: SecurityException) {
|
||||
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
|
||||
Log.w(TAG, "requestCellInfoUpdate in startScan failed", e)
|
||||
}
|
||||
|
||||
return
|
||||
} else if (SDK_INT >= 17) {
|
||||
val allCellInfo: List<CellInfo>? = try {
|
||||
telephonyManager.allCellInfo
|
||||
} catch (e: SecurityException) {
|
||||
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
|
||||
Log.w(TAG, "allCellInfo in startScan failed", e)
|
||||
null
|
||||
}
|
||||
if (allCellInfo != null) {
|
||||
val details = allCellInfo.map(CellInfo::toCellDetails).map { it.repair(context) }.filter(CellDetails::isValid)
|
||||
if (details.isNotEmpty()) {
|
||||
callback.onCellDetailsAvailable(details)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
val networkOperator = telephonyManager.networkOperator
|
||||
if (networkOperator != null && networkOperator.length > 4) {
|
||||
val mcc = networkOperator.substring(0, 3).toIntOrNull()
|
||||
val mnc = networkOperator.substring(3).toIntOrNull()
|
||||
val detail: CellDetails? = try {
|
||||
telephonyManager.cellLocation?.toCellDetails(mcc, mnc)
|
||||
} catch (e: SecurityException) {
|
||||
// It may trigger a SecurityException if the ACCESS_FINE_LOCATION permission isn't granted
|
||||
Log.w(TAG, "cellLocation in startScan failed", e)
|
||||
null
|
||||
}
|
||||
if (detail?.isValid == true) callback.onCellDetailsAvailable(listOf(detail))
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(context: Context, callback: CellDetailsCallback): CellDetailsSource = CellDetailsSource(context, callback)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.cell
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.SystemClock
|
||||
import android.telephony.CellIdentity
|
||||
import android.telephony.CellIdentityCdma
|
||||
import android.telephony.CellIdentityGsm
|
||||
import android.telephony.CellIdentityLte
|
||||
import android.telephony.CellIdentityNr
|
||||
import android.telephony.CellIdentityTdscdma
|
||||
import android.telephony.CellIdentityWcdma
|
||||
import android.telephony.CellInfo
|
||||
import android.telephony.CellInfoCdma
|
||||
import android.telephony.CellInfoGsm
|
||||
import android.telephony.CellInfoLte
|
||||
import android.telephony.CellInfoTdscdma
|
||||
import android.telephony.CellInfoWcdma
|
||||
import android.telephony.CellLocation
|
||||
import android.telephony.TelephonyManager
|
||||
import android.telephony.cdma.CdmaCellLocation
|
||||
import android.telephony.gsm.GsmCellLocation
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
private fun locationFromCdma(latitude: Int, longitude: Int) = if (latitude == Int.MAX_VALUE || longitude == Int.MAX_VALUE) null else Location("cdma").also {
|
||||
it.latitude = latitude.toDouble() / 14400.0
|
||||
it.longitude = longitude.toDouble() / 14400.0
|
||||
it.accuracy = 30000f
|
||||
}
|
||||
|
||||
private fun CdmaCellLocation.toCellDetails(timestamp: Long? = null) = CellDetails(
|
||||
type = CellDetails.Companion.Type.CDMA,
|
||||
sid = systemId,
|
||||
nid = networkId,
|
||||
bsid = baseStationId,
|
||||
location = locationFromCdma(baseStationLatitude, baseStationLongitude),
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
private fun GsmCellLocation.toCellDetails(mcc: Int? = null, mnc: Int? = null, timestamp: Long? = null) = CellDetails(
|
||||
type = CellDetails.Companion.Type.GSM,
|
||||
mcc = mcc,
|
||||
mnc = mnc,
|
||||
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
|
||||
pscOrPci = psc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
timestamp = timestamp
|
||||
)
|
||||
|
||||
internal fun CellLocation.toCellDetails(mcc: Int? = null, mnc: Int? = null, timestamp: Long? = null) = when (this) {
|
||||
is CdmaCellLocation -> toCellDetails(timestamp)
|
||||
is GsmCellLocation -> toCellDetails(mcc, mnc, timestamp)
|
||||
else -> throw IllegalArgumentException("Unknown CellLocation type")
|
||||
}
|
||||
|
||||
private fun CellIdentityCdma.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.CDMA,
|
||||
sid = systemId,
|
||||
nid = networkId,
|
||||
bsid = basestationId,
|
||||
location = locationFromCdma(latitude, longitude)
|
||||
)
|
||||
|
||||
private fun CellIdentityGsm.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.GSM,
|
||||
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong()
|
||||
)
|
||||
|
||||
private fun CellIdentityWcdma.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.WCDMA,
|
||||
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
|
||||
pscOrPci = psc.takeIf { it != Int.MAX_VALUE && it != -1 }
|
||||
)
|
||||
|
||||
private fun CellIdentityLte.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.LTE,
|
||||
mcc = if (SDK_INT >= 28) mccString?.toIntOrNull() else mcc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
mnc = if (SDK_INT >= 28) mncString?.toIntOrNull() else mnc.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
tac = tac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = ci.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong(),
|
||||
pscOrPci = pci.takeIf { it != Int.MAX_VALUE && it != -1 }
|
||||
)
|
||||
|
||||
@RequiresApi(28)
|
||||
private fun CellIdentityTdscdma.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.TDSCDMA,
|
||||
mcc = mccString?.toIntOrNull(),
|
||||
mnc = mncString?.toIntOrNull(),
|
||||
lac = lac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = cid.takeIf { it != Int.MAX_VALUE && it != -1 }?.toLong()
|
||||
)
|
||||
|
||||
@RequiresApi(29)
|
||||
private fun CellIdentityNr.toCellDetails() = CellDetails(
|
||||
type = CellDetails.Companion.Type.NR,
|
||||
mcc = mccString?.toIntOrNull(),
|
||||
mnc = mncString?.toIntOrNull(),
|
||||
tac = tac.takeIf { it != Int.MAX_VALUE && it != -1 },
|
||||
cid = nci.takeIf { it != Long.MAX_VALUE && it != -1L }
|
||||
)
|
||||
|
||||
@RequiresApi(28)
|
||||
internal fun CellIdentity.toCellDetails() = when {
|
||||
this is CellIdentityCdma -> toCellDetails()
|
||||
this is CellIdentityGsm -> toCellDetails()
|
||||
this is CellIdentityWcdma -> toCellDetails()
|
||||
this is CellIdentityLte -> toCellDetails()
|
||||
this is CellIdentityTdscdma -> toCellDetails()
|
||||
SDK_INT >= 29 && this is CellIdentityNr -> toCellDetails()
|
||||
else -> throw IllegalArgumentException("Unknown CellIdentity type")
|
||||
}
|
||||
|
||||
private val CellInfo.epochTimestamp: Long
|
||||
@RequiresApi(17)
|
||||
get() = if (SDK_INT >= 30) System.currentTimeMillis() - (SystemClock.elapsedRealtime() - timestampMillis)
|
||||
else System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() - timeStamp)
|
||||
|
||||
@RequiresApi(17)
|
||||
internal fun CellInfo.toCellDetails() = when {
|
||||
this is CellInfoCdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
this is CellInfoGsm -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
SDK_INT >= 18 && this is CellInfoWcdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
this is CellInfoLte -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
SDK_INT >= 29 && this is CellInfoTdscdma -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
SDK_INT >= 30 -> cellIdentity.toCellDetails().copy(timestamp = epochTimestamp, signalStrength = cellSignalStrength.dbm)
|
||||
else -> throw IllegalArgumentException("Unknown CellInfo type")
|
||||
}
|
||||
|
||||
/**
|
||||
* Fix a few known issues in Android's parsing of MNCs
|
||||
*/
|
||||
internal fun CellDetails.repair(context: Context): CellDetails {
|
||||
if (type == CellDetails.Companion.Type.CDMA) return this
|
||||
val networkOperator = context.getSystemService<TelephonyManager>()?.networkOperator ?: return this
|
||||
if (networkOperator.length < 5) return this
|
||||
val networkOperatorMnc = networkOperator.substring(3).toInt()
|
||||
if (networkOperator[3] == '0' && mnc == null || networkOperator.length == 5 && mnc == networkOperatorMnc * 10 + 15)
|
||||
return copy(mnc = networkOperatorMnc)
|
||||
return this
|
||||
}
|
||||
|
||||
val CellDetails.isValid: Boolean
|
||||
get() = when (type) {
|
||||
CellDetails.Companion.Type.CDMA -> sid != null && nid != null && bsid != null
|
||||
else -> mcc != null && mnc != null && cid != null && (lac != null || tac != null)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network
|
||||
|
||||
import android.location.Location
|
||||
import android.os.Bundle
|
||||
import androidx.core.location.LocationCompat
|
||||
import androidx.core.os.bundleOf
|
||||
import java.security.MessageDigest
|
||||
|
||||
const val TAG = "NetworkLocation"
|
||||
const val LOCATION_EXTRA_PRECISION = "precision"
|
||||
|
||||
const val PROVIDER_CACHE = "cache"
|
||||
const val PROVIDER_CACHE_NEGATIVE = "cache-"
|
||||
val NEGATIVE_CACHE_ENTRY = Location(PROVIDER_CACHE_NEGATIVE)
|
||||
|
||||
internal operator fun <T> Bundle?.plus(pair: Pair<String, T>): Bundle = this + bundleOf(pair)
|
||||
|
||||
internal operator fun Bundle?.plus(other: Bundle): Bundle = when {
|
||||
this == null -> other
|
||||
else -> Bundle(this).apply { putAll(other) }
|
||||
}
|
||||
|
||||
internal var Location.precision: Double
|
||||
get() = extras?.getDouble(LOCATION_EXTRA_PRECISION, 1.0) ?: 1.0
|
||||
set(value) {
|
||||
extras += LOCATION_EXTRA_PRECISION to value
|
||||
}
|
||||
|
||||
internal var Location.verticalAccuracy: Float?
|
||||
get() = if (LocationCompat.hasVerticalAccuracy(this)) LocationCompat.getVerticalAccuracyMeters(this) else null
|
||||
set(value) = if (value == null) LocationCompat.removeVerticalAccuracy(this) else LocationCompat.setVerticalAccuracyMeters(this, value)
|
||||
|
||||
fun ByteArray.toHexString(separator: String = "") : String = joinToString(separator) { "%02x".format(it) }
|
||||
fun ByteArray.digest(md: String): ByteArray = MessageDigest.getInstance(md).digest(this)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class BluetoothBeacon(
|
||||
/**
|
||||
* The address of the Bluetooth Low Energy (BLE) beacon.
|
||||
*/
|
||||
val macAddress: String? = null,
|
||||
/**
|
||||
* The name of the BLE beacon.
|
||||
*/
|
||||
val name: String? = null,
|
||||
/**
|
||||
* The number of milliseconds since this BLE beacon was last seen.
|
||||
*/
|
||||
val age: Long? = null,
|
||||
/**
|
||||
* The measured signal strength of the BLE beacon in dBm.
|
||||
*/
|
||||
val signalStrength: Int? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class CellTower(
|
||||
/**
|
||||
* The type of radio network.
|
||||
*/
|
||||
val radioType: RadioType? = null,
|
||||
/**
|
||||
* The mobile country code.
|
||||
*/
|
||||
val mobileCountryCode: Int? = null,
|
||||
/**
|
||||
* The mobile network code.
|
||||
*/
|
||||
val mobileNetworkCode: Int? = null,
|
||||
/**
|
||||
* The location area code for GSM and WCDMA networks. The tracking area code for LTE networks.
|
||||
*/
|
||||
val locationAreaCode: Int? = null,
|
||||
/**
|
||||
* The cell id or cell identity.
|
||||
*/
|
||||
val cellId: Int? = null,
|
||||
/**
|
||||
* The number of milliseconds since this networks was last detected.
|
||||
*/
|
||||
val age: Long? = null,
|
||||
/**
|
||||
* The primary scrambling code for WCDMA and physical cell id for LTE.
|
||||
*/
|
||||
val psc: Int? = null,
|
||||
/**
|
||||
* The signal strength for this cell network, either the RSSI or RSCP.
|
||||
*/
|
||||
val signalStrength: Int? = null,
|
||||
/**
|
||||
* The timing advance value for this cell network.
|
||||
*/
|
||||
val timingAdvance: Int? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
/**
|
||||
* By default, both a GeoIP based position fallback and a fallback based on cell location areas (lac’s) are enabled. Omit the fallbacks section if you want to use the defaults. Change the values to false if you want to disable either of the fallbacks.
|
||||
*/
|
||||
data class Fallback(
|
||||
/**
|
||||
* If no exact cell match can be found, fall back from exact cell position estimates to more coarse grained cell location area estimates rather than going directly to an even worse GeoIP based estimate.
|
||||
*/
|
||||
val lacf: Boolean? = null,
|
||||
/**
|
||||
* If no position can be estimated based on any of the provided data points, fall back to an estimate based on a GeoIP database based on the senders IP address at the time of the query.
|
||||
*/
|
||||
val ipf: Boolean? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class GeolocateRequest(
|
||||
/**
|
||||
* The clear text name of the cell carrier / operator.
|
||||
*/
|
||||
val carrier: String? = null,
|
||||
/**
|
||||
* Should the clients IP address be used to locate it; defaults to true.
|
||||
*/
|
||||
val considerIp: Boolean? = null,
|
||||
/**
|
||||
* The mobile country code stored on the SIM card.
|
||||
*/
|
||||
val homeMobileCountryCode: Int? = null,
|
||||
/**
|
||||
* The mobile network code stored on the SIM card.
|
||||
*/
|
||||
val homeMobileNetworkCode: Int? = null,
|
||||
/**
|
||||
* Same as the `radioType` entry in each cell record. If all the cell entries have the same `radioType`, it can be provided at the top level instead.
|
||||
*/
|
||||
val radioType: RadioType? = null,
|
||||
val bluetoothBeacons: List<BluetoothBeacon>? = null,
|
||||
val cellTowers: List<CellTower>? = null,
|
||||
val wifiAccessPoints: List<WifiAccessPoint>? = null,
|
||||
/**
|
||||
* By default, both a GeoIP based position fallback and a fallback based on cell location areas (lac’s) are enabled. Omit the fallbacks section if you want to use the defaults. Change the values to false if you want to disable either of the fallbacks.
|
||||
*/
|
||||
val fallbacks: Fallback? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class GeolocateResponse(
|
||||
val location: ResponseLocation? = null,
|
||||
val horizontalAccuracy: Double? = null,
|
||||
val fallback: String? = null,
|
||||
val error: ResponseError? = null,
|
||||
|
||||
// Custom
|
||||
val verticalAccuracy: Double? = null,
|
||||
val raw: List<RawGeolocateEntry> = emptyList(),
|
||||
)
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class GeosubmitItem(
|
||||
/**
|
||||
* The time of observation of the data, measured in milliseconds since the UNIX epoch. Can be omitted if the observation time is very recent. The age values in each section are relative to this timestamp.
|
||||
*/
|
||||
val timestamp: Long? = null,
|
||||
/**
|
||||
* The position block contains information about where and when the data was observed.
|
||||
*/
|
||||
val position: GeosubmitPosition? = null,
|
||||
val bluetoothBeacons: List<BluetoothBeacon>? = null,
|
||||
val cellTowers: List<CellTower>? = null,
|
||||
val wifiAccessPoints: List<WifiAccessPoint>? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class GeosubmitPosition(
|
||||
/**
|
||||
* The latitude of the observation (WSG 84).
|
||||
*/
|
||||
val latitude: Double? = null,
|
||||
/**
|
||||
* The longitude of the observation (WSG 84).
|
||||
*/
|
||||
val longitude: Double? = null,
|
||||
/**
|
||||
* The accuracy of the observed position in meters.
|
||||
*/
|
||||
val accuracy: Double? = null,
|
||||
/**
|
||||
* The altitude at which the data was observed in meters above sea-level.
|
||||
*/
|
||||
val altitude: Double? = null,
|
||||
/**
|
||||
* The accuracy of the altitude estimate in meters.
|
||||
*/
|
||||
val altitudeAccuracy: Double? = null,
|
||||
/**
|
||||
* The age of the position data (in milliseconds).
|
||||
*/
|
||||
val age: Long? = null,
|
||||
/**
|
||||
* The heading field denotes the direction of travel of the device and is specified in degrees, where 0° ≤ heading < 360°, counting clockwise relative to the true north.
|
||||
*/
|
||||
val heading: Double? = null,
|
||||
/**
|
||||
* The air pressure in hPa (millibar).
|
||||
*/
|
||||
val pressure: Double? = null,
|
||||
/**
|
||||
* The speed field denotes the magnitude of the horizontal component of the device’s current velocity and is specified in meters per second.
|
||||
*/
|
||||
val speed: Double? = null,
|
||||
/**
|
||||
* The source of the position information. If the field is omitted, “gps” is assumed. The term gps is used to cover all types of satellite based positioning systems including Galileo and Glonass. Other possible values are manual for a position entered manually into the system and fused for a position obtained from a combination of other sensors or outside service queries.
|
||||
*/
|
||||
val source: GeosubmitSource? = null,
|
||||
)
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class GeosubmitRequest(
|
||||
val items: List<GeosubmitItem>? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
enum class GeosubmitSource {
|
||||
GPS, MANUAL, FUSED;
|
||||
|
||||
override fun toString(): String {
|
||||
return super.toString().lowercase()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.collection.LruCache
|
||||
import com.android.volley.VolleyError
|
||||
import com.android.volley.toolbox.JsonObjectRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import org.json.JSONObject
|
||||
import org.microg.gms.location.LocationSettings
|
||||
import org.microg.gms.location.formatRealtime
|
||||
import org.microg.gms.location.network.*
|
||||
import org.microg.gms.location.network.cell.CellDetails
|
||||
import org.microg.gms.location.network.precision
|
||||
import org.microg.gms.location.network.verticalAccuracy
|
||||
import org.microg.gms.location.network.wifi.*
|
||||
import org.microg.gms.location.provider.BuildConfig
|
||||
import org.microg.gms.utils.singleInstanceOf
|
||||
import java.io.PrintWriter
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.LinkedList
|
||||
import kotlin.coroutines.Continuation
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.math.min
|
||||
|
||||
class IchnaeaServiceClient(private val context: Context) {
|
||||
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
|
||||
private val settings = LocationSettings(context)
|
||||
private val omitables = LinkedList<String>()
|
||||
private val cache = LruCache<String, Location>(REQUEST_CACHE_SIZE)
|
||||
private val start = SystemClock.elapsedRealtime()
|
||||
|
||||
private fun GeolocateRequest.hash(): ByteArray? {
|
||||
if (cellTowers.isNullOrEmpty() && (wifiAccessPoints?.size ?: 0) < 3 || bluetoothBeacons?.isNotEmpty() == true) return null
|
||||
val minAge = min(
|
||||
cellTowers?.takeIf { it.isNotEmpty() }?.minOf { it.age?.takeIf { it > 0L } ?: 0L } ?: Long.MAX_VALUE,
|
||||
wifiAccessPoints?.takeIf { it.isNotEmpty() }?.minOf { it.age?.takeIf { it > 0L } ?: 0L } ?: Long.MAX_VALUE
|
||||
)
|
||||
val buffer = ByteBuffer.allocate(8 + (cellTowers?.size ?: 0) * 23 + (wifiAccessPoints?.size ?: 0) * 8)
|
||||
buffer.putInt(cellTowers?.size?: 0)
|
||||
buffer.putInt(wifiAccessPoints?.size?: 0)
|
||||
for (cell in cellTowers.orEmpty()) {
|
||||
buffer.put(cell.radioType?.ordinal?.toByte() ?: -1)
|
||||
buffer.putInt(cell.mobileCountryCode ?: -1)
|
||||
buffer.putInt(cell.mobileNetworkCode ?: -1)
|
||||
buffer.putInt(cell.locationAreaCode ?: -1)
|
||||
buffer.putInt(cell.cellId ?: -1)
|
||||
buffer.putInt(cell.psc ?: -1)
|
||||
buffer.put(((cell.age?.let { it - minAge }?: 0L) / (60 * 1000)).toByte())
|
||||
buffer.put(((cell.signalStrength ?: 0) / 20).toByte())
|
||||
}
|
||||
for (wifi in wifiAccessPoints.orEmpty()) {
|
||||
buffer.put(wifi.macBytes)
|
||||
buffer.put(((wifi.age?.let { it - minAge }?: 0L) / (60 * 1000)).toByte())
|
||||
buffer.put(((wifi.signalStrength ?: 0) / 20).toByte())
|
||||
}
|
||||
return buffer.array().digest("SHA-256")
|
||||
}
|
||||
|
||||
fun isRequestable(wifi: WifiDetails): Boolean {
|
||||
return wifi.isRequestable && !omitables.contains(wifi.macClean)
|
||||
}
|
||||
|
||||
suspend fun retrieveMultiWifiLocation(wifis: List<WifiDetails>, rawHandler: ((WifiDetails, Location) -> Unit)? = null): Location? = geoLocate(
|
||||
GeolocateRequest(
|
||||
considerIp = false,
|
||||
wifiAccessPoints = wifis.filter { isRequestable(it) }.map(WifiDetails::toWifiAccessPoint),
|
||||
fallbacks = Fallback(lacf = false, ipf = false)
|
||||
),
|
||||
rawWifiHandler = rawHandler
|
||||
)?.apply {
|
||||
precision = wifis.size.toDouble() / WIFI_BASE_PRECISION_COUNT
|
||||
}
|
||||
|
||||
suspend fun retrieveSingleCellLocation(cell: CellDetails, rawHandler: ((CellDetails, Location) -> Unit)? = null): Location? = geoLocate(
|
||||
GeolocateRequest(
|
||||
considerIp = false,
|
||||
radioType = cell.toCellTower().radioType,
|
||||
homeMobileCountryCode = cell.toCellTower().mobileCountryCode,
|
||||
homeMobileNetworkCode = cell.toCellTower().mobileNetworkCode,
|
||||
cellTowers = listOf(cell.toCellTower()),
|
||||
fallbacks = Fallback(
|
||||
lacf = true,
|
||||
ipf = false
|
||||
)
|
||||
),
|
||||
rawCellHandler = rawHandler
|
||||
)?.apply {
|
||||
precision = if (extras?.getString(LOCATION_EXTRA_FALLBACK) != null) CELL_FALLBACK_PRECISION else CELL_DEFAULT_PRECISION
|
||||
}
|
||||
|
||||
private suspend fun geoLocate(
|
||||
request: GeolocateRequest,
|
||||
rawWifiHandler: ((WifiDetails, Location) -> Unit)? = null,
|
||||
rawCellHandler: ((CellDetails, Location) -> Unit)? = null
|
||||
): Location? {
|
||||
val requestHash = request.hash()
|
||||
if (requestHash != null) {
|
||||
val locationFromCache = cache[requestHash.toHexString()]
|
||||
if (locationFromCache == NEGATIVE_CACHE_ENTRY) return null
|
||||
if (locationFromCache != null) return Location(locationFromCache)
|
||||
}
|
||||
val response = rawGeoLocate(request)
|
||||
Log.d(TAG, "$request -> $response")
|
||||
for (entry in response.raw) {
|
||||
if (entry.omit && entry.wifiAccessPoint?.macAddress != null) {
|
||||
omitables.offer(entry.wifiAccessPoint.macAddress.lowercase().replace(":", ""))
|
||||
if (omitables.size > OMITABLES_LIMIT) omitables.remove()
|
||||
runCatching { rawWifiHandler?.invoke(entry.wifiAccessPoint.toWifiDetails(), NEGATIVE_CACHE_ENTRY) }
|
||||
}
|
||||
if (entry.omit && entry.cellTower?.radioType != null) {
|
||||
runCatching { rawCellHandler?.invoke(entry.cellTower.toCellDetails(), NEGATIVE_CACHE_ENTRY) }
|
||||
}
|
||||
if (!entry.omit && entry.wifiAccessPoint?.macAddress != null && entry.location != null) {
|
||||
val location = buildLocation(entry.location, entry.horizontalAccuracy, entry.verticalAccuracy).apply {
|
||||
precision = 1.0
|
||||
}
|
||||
runCatching { rawWifiHandler?.invoke(entry.wifiAccessPoint.toWifiDetails(), location) }
|
||||
}
|
||||
if (!entry.omit && entry.cellTower?.radioType != null && entry.location != null) {
|
||||
val location = buildLocation(entry.location, entry.horizontalAccuracy, entry.verticalAccuracy).apply {
|
||||
precision = 1.0
|
||||
}
|
||||
runCatching { rawCellHandler?.invoke(entry.cellTower.toCellDetails(), location) }
|
||||
}
|
||||
}
|
||||
val location = if (response.location != null) {
|
||||
buildLocation(response.location, response.horizontalAccuracy, response.verticalAccuracy).apply {
|
||||
if (response.fallback != null) extras = (extras ?: Bundle()).apply { putString(LOCATION_EXTRA_FALLBACK, response.fallback) }
|
||||
}
|
||||
} else if (response.error != null && response.error.code == 404) {
|
||||
NEGATIVE_CACHE_ENTRY
|
||||
} else if (response.error != null) {
|
||||
throw ServiceException(response.error)
|
||||
} else {
|
||||
throw RuntimeException("Invalid response JSON")
|
||||
}
|
||||
if (requestHash != null) {
|
||||
cache[requestHash.toHexString()] = if (location == NEGATIVE_CACHE_ENTRY) NEGATIVE_CACHE_ENTRY else Location(location)
|
||||
}
|
||||
if (location == NEGATIVE_CACHE_ENTRY) return null
|
||||
return location
|
||||
}
|
||||
|
||||
private fun buildLocation(location: ResponseLocation, defaultHorizontalAccuracy: Double? = null, defaultVerticalAccuracy: Double? = null): Location {
|
||||
return Location(PROVIDER).apply {
|
||||
latitude = location.latitude
|
||||
longitude = location.longitude
|
||||
if (location.altitude != null) altitude = location.altitude
|
||||
if (defaultHorizontalAccuracy != null && defaultHorizontalAccuracy > 0.0) accuracy = defaultHorizontalAccuracy.toFloat()
|
||||
if (hasAltitude() && defaultVerticalAccuracy != null && defaultVerticalAccuracy > 0.0) verticalAccuracy = defaultVerticalAccuracy.toFloat()
|
||||
if (location.horizontalAccuracy != null && location.horizontalAccuracy > 0.0) accuracy = location.horizontalAccuracy.toFloat()
|
||||
if (hasAltitude() && location.verticalAccuracy != null && location.verticalAccuracy > 0.0) verticalAccuracy = location.verticalAccuracy.toFloat()
|
||||
time = System.currentTimeMillis()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getRequestHeaders(): Map<String, String> = buildMap {
|
||||
set("User-Agent", "${BuildConfig.ICHNAEA_USER_AGENT} (Linux; Android ${android.os.Build.VERSION.RELEASE}; ${context.packageName})")
|
||||
if (settings.ichnaeaContribute) {
|
||||
set("X-Ichnaea-Contribute-Opt-In", "1")
|
||||
}
|
||||
}
|
||||
|
||||
private fun continueError(continuation: Continuation<GeolocateResponse>, error: VolleyError) {
|
||||
try {
|
||||
val response = JSONObject(error.networkResponse.data.decodeToString()).toGeolocateResponse()
|
||||
if (response.error != null) {
|
||||
continuation.resume(response)
|
||||
return
|
||||
} else if (response.location?.latitude != null){
|
||||
Log.w(TAG, "Received location in response with error code")
|
||||
} else {
|
||||
Log.w(TAG, "Received valid json without error in response with error code")
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
if (error.networkResponse != null) {
|
||||
continuation.resume(GeolocateResponse(error = ResponseError(error.networkResponse.statusCode, error.message)))
|
||||
return
|
||||
}
|
||||
continuation.resumeWithException(error)
|
||||
}
|
||||
|
||||
private suspend fun rawGeoLocate(request: GeolocateRequest): GeolocateResponse = suspendCoroutine { continuation ->
|
||||
val url = Uri.parse(settings.effectiveEndpoint).buildUpon().appendPath("v1").appendPath("geolocate").build().toString()
|
||||
queue.add(object : JsonObjectRequest(Method.POST, url, request.toJson(), {
|
||||
try {
|
||||
continuation.resume(it.toGeolocateResponse())
|
||||
} catch (e: Exception) {
|
||||
continuation.resumeWithException(e)
|
||||
}
|
||||
}, {
|
||||
continueError(continuation, it)
|
||||
}) {
|
||||
override fun getHeaders(): Map<String, String> = getRequestHeaders()
|
||||
})
|
||||
}
|
||||
|
||||
private suspend fun rawGeoSubmit(request: GeosubmitRequest): Unit = suspendCoroutine { continuation ->
|
||||
val url = Uri.parse(settings.effectiveEndpoint).buildUpon().appendPath("v2").appendPath("geosubmit").build().toString()
|
||||
queue.add(object : JsonObjectRequest(Method.POST, url, request.toJson(), {
|
||||
continuation.resume(Unit)
|
||||
}, {
|
||||
continuation.resumeWithException(it)
|
||||
}) {
|
||||
override fun getHeaders(): Map<String, String> = getRequestHeaders()
|
||||
})
|
||||
}
|
||||
|
||||
fun dump(writer: PrintWriter) {
|
||||
writer.println("Ichnaea start=${start.formatRealtime()} omitables=${omitables.size}")
|
||||
writer.println("Ichnaea request cache size=${cache.size()} hits=${cache.hitCount()} miss=${cache.missCount()} puts=${cache.putCount()} evicts=${cache.evictionCount()}")
|
||||
}
|
||||
|
||||
private operator fun <K : Any, V : Any> LruCache<K, V>.set(key: K, value: V) {
|
||||
put(key, value)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "IchnaeaLocation"
|
||||
private const val PROVIDER = "ichnaea"
|
||||
const val WIFI_BASE_PRECISION_COUNT = 4.0
|
||||
const val CELL_DEFAULT_PRECISION = 1.0
|
||||
const val CELL_FALLBACK_PRECISION = 0.5
|
||||
private const val OMITABLES_LIMIT = 100
|
||||
private const val REQUEST_CACHE_SIZE = 200
|
||||
const val LOCATION_EXTRA_FALLBACK = "fallback"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
enum class RadioType {
|
||||
GSM, WCDMA, LTE;
|
||||
|
||||
override fun toString(): String {
|
||||
return super.toString().lowercase()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class RawGeolocateEntry(
|
||||
val timestamp: Long? = null,
|
||||
|
||||
val bluetoothBeacon: BluetoothBeacon? = null,
|
||||
val cellTower: CellTower? = null,
|
||||
val wifiAccessPoint: WifiAccessPoint? = null,
|
||||
|
||||
val location: ResponseLocation? = null,
|
||||
val horizontalAccuracy: Double? = null,
|
||||
val verticalAccuracy: Double? = null,
|
||||
|
||||
val omit: Boolean = false,
|
||||
)
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class ResponseError(
|
||||
val code: Int? = null,
|
||||
val message: String? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class ResponseLocation(
|
||||
val latitude: Double,
|
||||
val longitude: Double,
|
||||
|
||||
// Custom
|
||||
val horizontalAccuracy: Double? = null,
|
||||
val altitude: Double? = null,
|
||||
val verticalAccuracy: Double? = null
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
class ServiceException(val error: ResponseError) : Exception(error.message)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
data class WifiAccessPoint(
|
||||
/**
|
||||
* The BSSID of the WiFi network.
|
||||
*/
|
||||
val macAddress: String,
|
||||
/**
|
||||
* The number of milliseconds since this network was last detected.
|
||||
*/
|
||||
val age: Long? = null,
|
||||
/**
|
||||
* The WiFi channel for networks in the 2.4GHz range. This often ranges from 1 to 13.
|
||||
*/
|
||||
val channel: Int? = null,
|
||||
/**
|
||||
* The frequency in MHz of the channel over which the client is communicating with the access point.
|
||||
*/
|
||||
val frequency: Int? = null,
|
||||
/**
|
||||
* The received signal strength (RSSI) in dBm.
|
||||
*/
|
||||
val signalStrength: Int? = null,
|
||||
/**
|
||||
* The current signal to noise ratio measured in dB.
|
||||
*/
|
||||
val signalToNoiseRatio: Int? = null,
|
||||
/**
|
||||
* The SSID of the Wifi network.
|
||||
*/
|
||||
val ssid: String? = null
|
||||
) {
|
||||
init {
|
||||
if (ssid != null && ssid.endsWith("_nomap")) throw IllegalArgumentException("Wifi networks with a SSID ending in _nomap must not be collected.")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.ichnaea
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import org.microg.gms.location.network.cell.CellDetails
|
||||
import org.microg.gms.location.network.wifi.WifiDetails
|
||||
|
||||
private fun JSONObject.getDouble(vararg names: String): Double {
|
||||
for (name in names) {
|
||||
if (has(name)) return getDouble(name)
|
||||
}
|
||||
throw JSONException("Values are all null: ${names.joinToString(", ")}")
|
||||
}
|
||||
|
||||
private fun JSONObject.getInt(vararg names: String): Int {
|
||||
for (name in names) {
|
||||
if (has(name)) return getInt(name)
|
||||
}
|
||||
throw JSONException("Values are all null: ${names.joinToString(", ")}")
|
||||
}
|
||||
|
||||
private fun JSONObject.getString(vararg names: String): String {
|
||||
for (name in names) {
|
||||
if (has(name)) return getString(name)
|
||||
}
|
||||
throw JSONException("Values are all null: ${names.joinToString(", ")}")
|
||||
}
|
||||
|
||||
private fun JSONObject.optDouble(vararg names: String): Double? {
|
||||
for (name in names) {
|
||||
if (has(name)) optDouble(name).takeIf { it.isFinite() }?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun JSONObject.optInt(vararg names: String): Int? {
|
||||
for (name in names) {
|
||||
runCatching { if (has(name)) return getInt(name) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun JSONObject.optLong(vararg names: String): Long? {
|
||||
for (name in names) {
|
||||
runCatching { if (has(name)) return getLong(name) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun JSONObject.optString(vararg names: String): String? {
|
||||
for (name in names) {
|
||||
if (has(name)) return optString(name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
internal fun CellDetails.toCellTower() = CellTower(
|
||||
radioType = when (type) {
|
||||
CellDetails.Companion.Type.GSM -> RadioType.GSM
|
||||
CellDetails.Companion.Type.WCDMA -> RadioType.WCDMA
|
||||
CellDetails.Companion.Type.LTE -> RadioType.LTE
|
||||
else -> throw IllegalArgumentException("Unsupported radio type")
|
||||
},
|
||||
mobileCountryCode = mcc,
|
||||
mobileNetworkCode = mnc,
|
||||
locationAreaCode = lac ?: tac,
|
||||
cellId = cid?.toInt(),
|
||||
age = timestamp?.let { System.currentTimeMillis() - it },
|
||||
psc = pscOrPci,
|
||||
signalStrength = signalStrength
|
||||
)
|
||||
|
||||
internal fun CellTower.toCellDetails() = CellDetails(
|
||||
type = when(radioType) {
|
||||
RadioType.GSM -> CellDetails.Companion.Type.GSM
|
||||
RadioType.WCDMA -> CellDetails.Companion.Type.WCDMA
|
||||
RadioType.LTE -> CellDetails.Companion.Type.LTE
|
||||
else -> throw IllegalArgumentException("Unsupported radio type")
|
||||
},
|
||||
mcc = mobileNetworkCode,
|
||||
mnc = mobileNetworkCode,
|
||||
lac = locationAreaCode,
|
||||
tac = locationAreaCode,
|
||||
cid = cellId?.toLong(),
|
||||
pscOrPci = psc,
|
||||
signalStrength = signalStrength
|
||||
)
|
||||
|
||||
internal fun WifiDetails.toWifiAccessPoint() = WifiAccessPoint(
|
||||
macAddress = macAddress,
|
||||
age = timestamp?.let { System.currentTimeMillis() - it },
|
||||
channel = channel,
|
||||
frequency = frequency,
|
||||
signalStrength = signalStrength,
|
||||
ssid = ssid
|
||||
)
|
||||
|
||||
internal fun WifiAccessPoint.toWifiDetails() = WifiDetails(
|
||||
macAddress = macAddress,
|
||||
channel = channel,
|
||||
frequency = frequency,
|
||||
signalStrength = signalStrength,
|
||||
ssid = ssid,
|
||||
)
|
||||
|
||||
internal fun JSONObject.toGeolocateResponse() = GeolocateResponse(
|
||||
location = optJSONObject("location")?.toResponseLocation(),
|
||||
horizontalAccuracy = optDouble("accuracy", "acc", "horizontalAccuracy"),
|
||||
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
|
||||
fallback = optString("fallback").takeIf { it.isNotEmpty() },
|
||||
raw = optJSONArray("raw")?.toRawGeolocateEntries().orEmpty(),
|
||||
error = optJSONObject("error")?.toResponseError()
|
||||
)
|
||||
|
||||
internal fun GeolocateRequest.toJson() = JSONObject().apply {
|
||||
if (carrier != null) put("carrier", carrier)
|
||||
if (considerIp != null) put("considerIp", considerIp)
|
||||
if (homeMobileCountryCode != null) put("homeMobileCountryCode", homeMobileCountryCode)
|
||||
if (homeMobileNetworkCode != null) put("homeMobileNetworkCode", homeMobileNetworkCode)
|
||||
if (radioType != null) put("radioType", radioType.toString())
|
||||
if (!bluetoothBeacons.isNullOrEmpty()) put("bluetoothBeacons", JSONArray(bluetoothBeacons.map(BluetoothBeacon::toJson)))
|
||||
if (!cellTowers.isNullOrEmpty()) put("cellTowers", JSONArray(cellTowers.map(CellTower::toJson)))
|
||||
if (!wifiAccessPoints.isNullOrEmpty()) put("wifiAccessPoints", JSONArray(wifiAccessPoints.map(WifiAccessPoint::toJson)))
|
||||
if (fallbacks != null) put("fallbacks", fallbacks.toJson())
|
||||
}
|
||||
|
||||
internal fun GeosubmitRequest.toJson() = JSONObject().apply {
|
||||
if (items != null) put("items", JSONArray(items.map(GeosubmitItem::toJson)))
|
||||
}
|
||||
|
||||
private fun GeosubmitItem.toJson() = JSONObject().apply {
|
||||
if (timestamp != null) put("timestamp", timestamp)
|
||||
if (position != null) put("position", position.toJson())
|
||||
if (!bluetoothBeacons.isNullOrEmpty()) put("bluetoothBeacons", JSONArray(bluetoothBeacons.map(BluetoothBeacon::toJson)))
|
||||
if (!cellTowers.isNullOrEmpty()) put("cellTowers", JSONArray(cellTowers.map(CellTower::toJson)))
|
||||
if (!wifiAccessPoints.isNullOrEmpty()) put("wifiAccessPoints", JSONArray(wifiAccessPoints.map(WifiAccessPoint::toJson)))
|
||||
}
|
||||
|
||||
private fun GeosubmitPosition.toJson() = JSONObject().apply {
|
||||
if (latitude != null) put("latitude", latitude)
|
||||
if (longitude != null) put("longitude", longitude)
|
||||
if (accuracy != null) put("accuracy", accuracy)
|
||||
if (altitude != null) put("altitude", altitude)
|
||||
if (altitudeAccuracy != null) put("altitudeAccuracy", altitudeAccuracy)
|
||||
if (age != null) put("age", age)
|
||||
if (heading != null) put("heading", heading)
|
||||
if (pressure != null) put("pressure", pressure)
|
||||
if (speed != null) put("speed", speed)
|
||||
if (source != null) put("source", source.toString())
|
||||
}
|
||||
|
||||
private fun JSONObject.toResponseLocation() = ResponseLocation(
|
||||
latitude = getDouble("lat", "latitude"),
|
||||
longitude = getDouble("lng", "longitude"),
|
||||
altitude = optDouble("alt", "altitude"),
|
||||
horizontalAccuracy = optDouble("acc", "accuracy", "horizontalAccuracy"),
|
||||
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
|
||||
)
|
||||
|
||||
private fun JSONObject.toResponseError() = ResponseError(
|
||||
code = optInt("code", "errorCode", "statusCode"),
|
||||
message = optString("message", "msg", "statusText")
|
||||
)
|
||||
|
||||
private fun JSONArray.toRawGeolocateEntries(): List<RawGeolocateEntry> =
|
||||
(0 until length()).mapNotNull { optJSONObject(it)?.toRawGeolocateEntry() }
|
||||
|
||||
private fun JSONObject.toRawGeolocateEntry() = RawGeolocateEntry(
|
||||
timestamp = optLong("time", "timestamp"),
|
||||
bluetoothBeacon = optJSONObject("bluetoothBeacon")?.toBluetoothBeacon(),
|
||||
cellTower = optJSONObject("cellTower")?.toCellTower(),
|
||||
wifiAccessPoint = optJSONObject("wifiAccessPoint")?.toWifiAccessPoint(),
|
||||
location = optJSONObject("location")?.toResponseLocation(),
|
||||
horizontalAccuracy = optDouble("acc", "accuracy", "horizontalAccuracy"),
|
||||
verticalAccuracy = optDouble("altAccuracy", "altitudeAccuracy", "verticalAccuracy"),
|
||||
omit = optBoolean("omit")
|
||||
)
|
||||
|
||||
private fun JSONObject.toBluetoothBeacon() = BluetoothBeacon(
|
||||
macAddress = getString("macAddress", "mac", "address"),
|
||||
name = optString("name"),
|
||||
)
|
||||
|
||||
private fun JSONObject.toCellTower() = CellTower(
|
||||
radioType = optString("radioType", "radio", "type")?.let { runCatching { RadioType.valueOf(it.uppercase()) }.getOrNull() },
|
||||
mobileCountryCode = optInt("mobileCountryCode", "mcc"),
|
||||
mobileNetworkCode = optInt("mobileNetworkCode", "mnc"),
|
||||
locationAreaCode = optInt("locationAreaCode", "lac", "trackingAreaCode", "tac"),
|
||||
cellId = optInt("cellId", "cellIdentity", "cid"),
|
||||
psc = optInt("psc", "primaryScramblingCode", "physicalCellId", "pci"),
|
||||
)
|
||||
|
||||
private fun JSONObject.toWifiAccessPoint() = WifiAccessPoint(
|
||||
macAddress = getString("macAddress", "mac", "bssid", "address"),
|
||||
channel = optInt("channel", "chan"),
|
||||
frequency = optInt("frequency", "freq"),
|
||||
ssid = optString("ssid"),
|
||||
)
|
||||
|
||||
private fun BluetoothBeacon.toJson() = JSONObject().apply {
|
||||
if (macAddress != null) put("macAddress", macAddress)
|
||||
if (name != null) put("name", name)
|
||||
if (age != null) put("age", age)
|
||||
if (signalStrength != null) put("signalStrength", signalStrength)
|
||||
}
|
||||
|
||||
private fun CellTower.toJson() = JSONObject().apply {
|
||||
if (radioType != null) put("radioType", radioType.toString())
|
||||
if (mobileCountryCode != null) put("mobileCountryCode", mobileCountryCode)
|
||||
if (mobileNetworkCode != null) put("mobileNetworkCode", mobileNetworkCode)
|
||||
if (locationAreaCode != null) put("locationAreaCode", locationAreaCode)
|
||||
if (cellId != null) put("cellId", cellId)
|
||||
if (age != null) put("age", age)
|
||||
if (psc != null) put("psc", psc)
|
||||
if (signalStrength != null) put("signalStrength", signalStrength)
|
||||
if (timingAdvance != null) put("timingAdvance", timingAdvance)
|
||||
}
|
||||
|
||||
private fun WifiAccessPoint.toJson() = JSONObject().apply {
|
||||
put("macAddress", macAddress)
|
||||
if (age != null) put("age", age)
|
||||
if (channel != null) put("channel", channel)
|
||||
if (frequency != null) put("frequency", frequency)
|
||||
if (signalStrength != null) put("signalStrength", signalStrength)
|
||||
if (signalToNoiseRatio != null) put("signalToNoiseRatio", signalToNoiseRatio)
|
||||
if (ssid != null) put("ssid", ssid)
|
||||
}
|
||||
|
||||
val WifiAccessPoint.macClean: String
|
||||
get() = macAddress.lowercase().replace(":", "")
|
||||
|
||||
val WifiAccessPoint.macBytes: ByteArray
|
||||
get() {
|
||||
val mac = macClean
|
||||
return byteArrayOf(
|
||||
mac.substring(0, 2).toInt(16).toByte(),
|
||||
mac.substring(2, 4).toInt(16).toByte(),
|
||||
mac.substring(4, 6).toInt(16).toByte(),
|
||||
mac.substring(6, 8).toInt(16).toByte(),
|
||||
mac.substring(8, 10).toInt(16).toByte(),
|
||||
mac.substring(10, 12).toInt(16).toByte()
|
||||
)
|
||||
}
|
||||
|
||||
val GeolocateRequest.isWifiOnly: Boolean
|
||||
get() = bluetoothBeacons.isNullOrEmpty() && cellTowers.isNullOrEmpty() && wifiAccessPoints?.isNotEmpty() == true
|
||||
|
||||
val GeolocateRequest.isCellOnly: Boolean
|
||||
get() = bluetoothBeacons.isNullOrEmpty() && wifiAccessPoints.isNullOrEmpty() && cellTowers?.isNotEmpty() == true
|
||||
|
||||
private fun Fallback.toJson() = JSONObject().apply {
|
||||
if (lacf != null) put("lacf", lacf)
|
||||
if (ipf != null) put("ipf", ipf)
|
||||
}
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.ConnectivityManager.TYPE_WIFI
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.location.LocationCompat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.microg.gms.location.network.TAG
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.Proxy
|
||||
import java.net.URL
|
||||
import java.security.KeyStore
|
||||
import java.security.cert.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import javax.net.ssl.CertPathTrustManagerParameters
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.TrustManagerFactory
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
|
||||
private val MOVING_WIFI_HOTSPOTS = setOf(
|
||||
// Austria
|
||||
"OEBB",
|
||||
"Austrian FlyNet",
|
||||
"svciob", // OEBB Service WIFI
|
||||
// Belgium
|
||||
"THALYSNET",
|
||||
// Canada
|
||||
"Air Canada",
|
||||
"ACWiFi",
|
||||
"ACWiFi.com",
|
||||
// Czech Republic
|
||||
"CDWiFi",
|
||||
// France
|
||||
"_SNCF_WIFI_INOUI",
|
||||
"_SNCF_WIFI_INTERCITES",
|
||||
"_WIFI_LYRIA",
|
||||
"OUIFI",
|
||||
"NormandieTrainConnecte",
|
||||
// Germany
|
||||
"WIFIonICE",
|
||||
"WIFI@DB",
|
||||
"WiFi@DB",
|
||||
"RRX Hotspot",
|
||||
"FlixBux",
|
||||
"FlixBus Wi-Fi",
|
||||
"FlixTrain Wi-Fi",
|
||||
"FlyNet",
|
||||
"Telekom_FlyNet",
|
||||
"Vestische WLAN",
|
||||
"agilis-Wifi",
|
||||
"freeWIFIahead!",
|
||||
// Greece
|
||||
"AegeanWiFi",
|
||||
// Hong Kong
|
||||
"Cathay Pacific",
|
||||
// Hungary
|
||||
"MAVSTART-WIFI",
|
||||
// Netherlands
|
||||
"KEOLIS Nederland",
|
||||
// New Zealand
|
||||
"AirNZ_InflightWiFi",
|
||||
"Bluebridge WiFi",
|
||||
// Singapore
|
||||
"KrisWorld",
|
||||
// Sweden
|
||||
"SJ",
|
||||
"saswifi",
|
||||
// Switzerland
|
||||
"SBB-Free",
|
||||
"SBB-FREE",
|
||||
"SWISS Connect",
|
||||
"Edelweiss Entertainment",
|
||||
// United Kingdom
|
||||
"Avanti_Free_WiFi",
|
||||
"CrossCountryWiFi",
|
||||
"GWR WiFi",
|
||||
"LNR On Board Wi-Fi",
|
||||
"LOOP on train WiFi",
|
||||
"WMR On Board Wi-Fi",
|
||||
"EurostarTrainsWiFi",
|
||||
// United States
|
||||
"Amtrak_WiFi",
|
||||
)
|
||||
|
||||
private val PHONE_HOTSPOT_KEYWORDS = setOf(
|
||||
"iPhone",
|
||||
"Galaxy",
|
||||
"AndroidAP"
|
||||
)
|
||||
|
||||
/**
|
||||
* A Wi-Fi hotspot that changes its location dynamically and thus is unsuitable for use with location services that assume stable locations.
|
||||
*
|
||||
* Some moving Wi-Fi hotspots allow to determine their location when connected or through a public network API.
|
||||
*/
|
||||
val WifiDetails.isMoving: Boolean
|
||||
get() {
|
||||
if (MOVING_WIFI_HOTSPOTS.contains(ssid)) {
|
||||
return true
|
||||
}
|
||||
if (PHONE_HOTSPOT_KEYWORDS.any { ssid?.contains(it) == true }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const val FEET_TO_METERS = 0.3048
|
||||
const val KNOTS_TO_METERS_PER_SECOND = 0.5144
|
||||
const val MILES_PER_HOUR_TO_METERS_PER_SECOND = 0.447
|
||||
|
||||
class MovingWifiHelper(private val context: Context) {
|
||||
suspend fun retrieveMovingLocation(current: WifiDetails): Location {
|
||||
if (!isLocallyRetrievable(current)) throw IllegalArgumentException()
|
||||
val connectivityManager = context.getSystemService<ConnectivityManager>() ?: throw IllegalStateException()
|
||||
val sources = MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE[current.ssid]!!
|
||||
val exceptions = mutableListOf<Exception>()
|
||||
for (source in sources) {
|
||||
try {
|
||||
val url = URL(source.url)
|
||||
return withContext(Dispatchers.IO) {
|
||||
val network = if (isLocallyRetrievable(current) && SDK_INT >= 23) {
|
||||
@Suppress("DEPRECATION")
|
||||
(connectivityManager.allNetworks.singleOrNull {
|
||||
val networkInfo = connectivityManager.getNetworkInfo(it)
|
||||
networkInfo?.type == TYPE_WIFI && networkInfo.isConnected
|
||||
})
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val connection = (if (SDK_INT >= 23) {
|
||||
network?.openConnection(url, Proxy.NO_PROXY)
|
||||
} else {
|
||||
null
|
||||
} ?: url.openConnection()) as HttpURLConnection
|
||||
try {
|
||||
connection.doInput = true
|
||||
if (connection is HttpsURLConnection && SDK_INT >= 24) {
|
||||
try {
|
||||
val ctx = SSLContext.getInstance("TLS")
|
||||
val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||
fun wrap(originalTrustManager: TrustManager): TrustManager {
|
||||
if (originalTrustManager is X509TrustManager) {
|
||||
return object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
Log.d(TAG, "checkClientTrusted: $chain, $authType")
|
||||
originalTrustManager.checkClientTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) {
|
||||
Log.d(TAG, "checkServerTrusted: $chain, $authType")
|
||||
originalTrustManager.checkServerTrusted(chain, authType)
|
||||
}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> {
|
||||
return originalTrustManager.acceptedIssuers
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return originalTrustManager
|
||||
}
|
||||
}
|
||||
val ks = KeyStore.getInstance("AndroidCAStore")
|
||||
ks.load(null, null)
|
||||
tmf.init(ks)
|
||||
ctx.init(null, tmf.trustManagers.map(::wrap).toTypedArray(), null)
|
||||
connection.sslSocketFactory = ctx.socketFactory
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to disable revocation", e)
|
||||
}
|
||||
}
|
||||
if (connection.responseCode != 200) throw RuntimeException("Got error")
|
||||
val location = Location(current.ssid ?: "wifi")
|
||||
source.parse(location, connection.inputStream.readBytes())
|
||||
} finally {
|
||||
connection.inputStream.close()
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
exceptions.add(e)
|
||||
}
|
||||
}
|
||||
if (exceptions.size == 1) throw exceptions.single()
|
||||
throw RuntimeException(exceptions.joinToString("\n"))
|
||||
}
|
||||
|
||||
fun isLocallyRetrievable(wifi: WifiDetails): Boolean =
|
||||
MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE.containsKey(wifi.ssid)
|
||||
|
||||
companion object {
|
||||
abstract class MovingWifiLocationSource(val url: String) {
|
||||
abstract fun parse(location: Location, data: ByteArray): Location
|
||||
}
|
||||
|
||||
private val SOURCE_WIFI_ON_ICE = object : MovingWifiLocationSource("https://iceportal.de/api1/rs/status") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
if (json.getString("gpsStatus") != "VALID") throw RuntimeException("GPS not valid")
|
||||
location.accuracy = 100f
|
||||
location.time = json.getLong("serverTime") - 15000L
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it / 3.6).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_OEBB_1 = object : MovingWifiLocationSource("https://railnet.oebb.at/assets/modules/fis/combined.json") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString()).getJSONObject("latestStatus")
|
||||
location.accuracy = 100f
|
||||
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("dateTime"))?.time }.getOrNull()?.let { location.time = it }
|
||||
location.latitude = json.getJSONObject("gpsPosition").getDouble("latitude")
|
||||
location.longitude = json.getJSONObject("gpsPosition").getDouble("longitude")
|
||||
json.getJSONObject("gpsPosition").optDouble("orientation").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it / 3.6).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_OEBB_2 = object : MovingWifiLocationSource("https://railnet.oebb.at/api/gps") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val root = JSONObject(data.decodeToString())
|
||||
if (root.has("JSON")) {
|
||||
val json = root.getJSONObject("JSON")
|
||||
if (!json.isNull("error")) throw RuntimeException("Error: ${json.get("error")}");
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("lat")
|
||||
location.longitude = json.getDouble("lon")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it / 3.6).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
} else if (root.optDouble("Latitude").let { !it.isNaN() && it.isFinite() && it > 0.1 }) {
|
||||
location.accuracy = 100f
|
||||
location.latitude = root.getDouble("Latitude")
|
||||
location.longitude = root.getDouble("Longitude")
|
||||
} else {
|
||||
throw RuntimeException("Unsupported: $root")
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_FLIXBUS = object : MovingWifiLocationSource("https://media.flixbus.com/services/pis/v1/position") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
class PassengeraLocationSource(base: String) : MovingWifiLocationSource("$base/portal/api/vehicle/realtime") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("gpsLat")
|
||||
location.longitude = json.getDouble("gpsLng")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it / 3.6).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it }
|
||||
return location
|
||||
}
|
||||
}
|
||||
private val SOURCE_PASSENGERA_MAV = PassengeraLocationSource("http://portal.mav.hu")
|
||||
private val SOURCE_PASSENGERA_CD = PassengeraLocationSource("http://cdwifi.cz")
|
||||
|
||||
private val SOURCE_DISPLAY_UGO = object : MovingWifiLocationSource("https://api.ife.ugo.aero/navigation/positions") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONArray(data.decodeToString()).getJSONObject(0)
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("created_at"))?.time }.getOrNull()?.let { location.time = it }
|
||||
json.optDouble("speed_kilometers_per_hour").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it / 3.6).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
json.optDouble("altitude_meters").takeIf { !it.isNaN() }?.let { location.altitude = it }
|
||||
json.optDouble("bearing_in_degree").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_INFLIGHT_PANASONIC = object : MovingWifiLocationSource("https://services.inflightpanasonic.aero/inflight/services/flightdata/v2/flightdata") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getJSONObject("current_coordinates").getDouble("latitude")
|
||||
location.longitude = json.getJSONObject("current_coordinates").getDouble("longitude")
|
||||
json.optDouble("ground_speed_knots").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it * KNOTS_TO_METERS_PER_SECOND).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
json.optDouble("altitude_feet").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
|
||||
json.optDouble("true_heading_degree").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
class BoardConnectLocationSource(base: String) : MovingWifiLocationSource("$base/map/api/flightData") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("lat")
|
||||
location.longitude = json.getDouble("lon")
|
||||
runCatching { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.US).parse(json.getString("utc"))?.time }.getOrNull()?.let { location.time = it }
|
||||
json.optDouble("groundSpeed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it * KNOTS_TO_METERS_PER_SECOND).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
|
||||
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
private val SOURCE_LUFTHANSA_FLYNET_EUROPE = BoardConnectLocationSource("https://www.lufthansa-flynet.com")
|
||||
private val SOURCE_LUFTHANSA_FLYNET_EUROPE_2 = BoardConnectLocationSource("https://ww2.lufthansa-flynet.com")
|
||||
private val SOURCE_AUSTRIAN_FLYNET_EUROPE = BoardConnectLocationSource("https://www.austrian-flynet.com")
|
||||
|
||||
class SncfLocationSource(base: String) : MovingWifiLocationSource("$base/router/api/train/gps") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
if(json.has("fix") && json.getInt("fix") == -1) throw RuntimeException("GPS not valid")
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
location.time = json.getLong("timestamp")
|
||||
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
private val SOURCE_SNCF = SncfLocationSource("https://wifi.sncf")
|
||||
private val SOURCE_SNCF_INTERCITES = SncfLocationSource("https://wifi.intercites.sncf")
|
||||
private val SOURCE_NORMANDIE = SncfLocationSource("https://wifi.normandie.fr")
|
||||
|
||||
private val SOURCE_LYRIA = object : MovingWifiLocationSource("https://wifi.tgv-lyria.com/api/train/gps/position/") {
|
||||
/* If there is no location available (e.g. in a tunnel), the API
|
||||
endpoint returns HTTP 500, though it may reuse a previous
|
||||
location for a few seconds. The returned JSON has a
|
||||
"satellites" integer field, but this always seems to be 0, even
|
||||
when there is a valid location available.
|
||||
*/
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
// Speed is returned in m/s.
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it }
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_OUIFI = object : MovingWifiLocationSource("https://ouifi.ouigo.com:8084/api/gps") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
if(json.has("fix") && json.getInt("fix") == -1) throw RuntimeException("GPS not valid")
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
location.time = json.getLong("timestamp")
|
||||
json.optDouble("heading").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_OMBORD = object : MovingWifiLocationSource("https://www.ombord.info/api/jsonp/position/") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
// The API endpoint returns a JSONP object (even when no ?callback= is supplied), so strip the surrounding function call.
|
||||
val json = JSONObject(data.decodeToString().trim().trim('(', ')', ';'))
|
||||
// TODO: what happens in the Channel Tunnel? Does "satellites" go to zero? Does "mode" change?
|
||||
if (json.has("satellites") && json.getInt("satellites") < 1) throw RuntimeException("Ombord has no GPS fix")
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
location.time = json.getLong("time")
|
||||
// "cmg" means "course made good", i.e. the compass heading of the track over the ground.
|
||||
// Sometimes gets stuck for a few minutes, so use a generous accuracy value.
|
||||
json.optDouble("cmg").takeIf { !it.isNaN() }?.let {
|
||||
location.bearing = it.toFloat()
|
||||
LocationCompat.setBearingAccuracyDegrees(location, 90f)
|
||||
}
|
||||
json.optDouble("altitude").takeIf { !it.isNaN() }?.let {
|
||||
location.altitude = it
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_AIR_CANADA = object : MovingWifiLocationSource("https://airbornemedia.inflightinternet.com/asp/api/flight/info") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString()).getJSONObject("gpsData")
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("latitude")
|
||||
location.longitude = json.getDouble("longitude")
|
||||
json.optLong("utcTime").takeIf { it != 0L }?.let { location.time = it }
|
||||
json.optDouble("altitude").takeIf { !it.isNaN() }?.let { location.altitude = it * FEET_TO_METERS }
|
||||
json.optDouble("horizontalVelocity").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = (it * MILES_PER_HOUR_TO_METERS_PER_SECOND).toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val SOURCE_HOTSPLOTS = object : MovingWifiLocationSource("http://hsp.hotsplots.net/status.json") {
|
||||
override fun parse(location: Location, data: ByteArray): Location {
|
||||
val json = JSONObject(data.decodeToString())
|
||||
location.accuracy = 100f
|
||||
location.latitude = json.getDouble("lat")
|
||||
location.longitude = json.getDouble("lng")
|
||||
json.optLong("ts").takeIf { it != 0L }?.let { location.time = it * 1000 }
|
||||
json.optDouble("speed").takeIf { !it.isNaN() }?.let {
|
||||
location.speed = it.toFloat()
|
||||
LocationCompat.setSpeedAccuracyMetersPerSecond(location, location.speed * 0.1f)
|
||||
}
|
||||
return location
|
||||
}
|
||||
}
|
||||
|
||||
private val MOVING_WIFI_HOTSPOTS_LOCALLY_RETRIEVABLE: Map<String, List<MovingWifiLocationSource>> = mapOf(
|
||||
"WIFIonICE" to listOf(SOURCE_WIFI_ON_ICE),
|
||||
"OEBB" to listOf(SOURCE_OEBB_2, SOURCE_OEBB_1),
|
||||
"FlixBus" to listOf(SOURCE_FLIXBUS),
|
||||
"FlixBus Wi-Fi" to listOf(SOURCE_FLIXBUS),
|
||||
"FlixTrain Wi-Fi" to listOf(SOURCE_FLIXBUS),
|
||||
"MAVSTART-WIFI" to listOf(SOURCE_PASSENGERA_MAV),
|
||||
"AegeanWiFi" to listOf(SOURCE_DISPLAY_UGO),
|
||||
"Telekom_FlyNet" to listOf(SOURCE_INFLIGHT_PANASONIC),
|
||||
"Cathay Pacific" to listOf(SOURCE_INFLIGHT_PANASONIC),
|
||||
"KrisWorld" to listOf(SOURCE_INFLIGHT_PANASONIC),
|
||||
"SWISS Connect" to listOf(SOURCE_INFLIGHT_PANASONIC),
|
||||
"Edelweiss Entertainment" to listOf(SOURCE_INFLIGHT_PANASONIC),
|
||||
"FlyNet" to listOf(SOURCE_LUFTHANSA_FLYNET_EUROPE, SOURCE_LUFTHANSA_FLYNET_EUROPE_2),
|
||||
"CDWiFi" to listOf(SOURCE_PASSENGERA_CD),
|
||||
"Air Canada" to listOf(SOURCE_AIR_CANADA),
|
||||
"ACWiFi" to listOf(SOURCE_AIR_CANADA),
|
||||
"ACWiFi.com" to listOf(SOURCE_AIR_CANADA),
|
||||
"OUIFI" to listOf(SOURCE_OUIFI),
|
||||
"_SNCF_WIFI_INOUI" to listOf(SOURCE_SNCF),
|
||||
"_SNCF_WIFI_INTERCITES" to listOf(SOURCE_SNCF_INTERCITES),
|
||||
"_WIFI_LYRIA" to listOf(SOURCE_LYRIA),
|
||||
"NormandieTrainConnecte" to listOf(SOURCE_NORMANDIE),
|
||||
"agilis-Wifi" to listOf(SOURCE_HOTSPLOTS),
|
||||
"Austrian FlyNet" to listOf(SOURCE_AUSTRIAN_FLYNET_EUROPE),
|
||||
"EurostarTrainsWiFi" to listOf(SOURCE_OMBORD),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import org.microg.gms.location.network.NetworkDetails
|
||||
|
||||
data class WifiDetails(
|
||||
val macAddress: String,
|
||||
val ssid: String? = null,
|
||||
val frequency: Int? = null,
|
||||
val channel: Int? = null,
|
||||
override val timestamp: Long? = null,
|
||||
override val signalStrength: Int? = null,
|
||||
val open: Boolean = false
|
||||
): NetworkDetails
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
interface WifiDetailsCallback {
|
||||
fun onWifiDetailsAvailable(wifis: List<WifiDetails>)
|
||||
fun onWifiSourceFailed()
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import android.content.Context
|
||||
import android.os.WorkSource
|
||||
|
||||
interface WifiDetailsSource {
|
||||
fun enable() = Unit
|
||||
fun disable() = Unit
|
||||
fun startScan(workSource: WorkSource?) = Unit
|
||||
|
||||
companion object {
|
||||
fun create(context: Context, callback: WifiDetailsCallback) = when {
|
||||
WifiScannerSource.isSupported(context) -> WifiScannerSource(context, callback)
|
||||
else -> WifiManagerSource(context, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.WorkSource
|
||||
import android.util.Log
|
||||
import androidx.core.content.getSystemService
|
||||
|
||||
class WifiManagerSource(private val context: Context, private val callback: WifiDetailsCallback) : BroadcastReceiver(), WifiDetailsSource {
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
try {
|
||||
callback.onWifiDetailsAvailable(this.context.getSystemService<WifiManager>()?.scanResults.orEmpty().map(ScanResult::toWifiDetails))
|
||||
} catch (e: Exception) {
|
||||
Log.w(org.microg.gms.location.network.TAG, e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable() {
|
||||
context.registerReceiver(this, IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION))
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
context.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
override fun startScan(workSource: WorkSource?) {
|
||||
context.getSystemService<WifiManager>()?.startScan()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WifiScanner
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.WorkSource
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import org.microg.gms.location.network.TAG
|
||||
|
||||
@SuppressLint("WrongConstant")
|
||||
class WifiScannerSource(private val context: Context, private val callback: WifiDetailsCallback) : WifiDetailsSource {
|
||||
override fun startScan(workSource: WorkSource?) {
|
||||
val scanner = context.getSystemService("wifiscanner") as WifiScanner
|
||||
scanner.startScan(WifiScanner.ScanSettings().apply {
|
||||
band = WifiScanner.WIFI_BAND_BOTH
|
||||
}, object : WifiScanner.ScanListener {
|
||||
override fun onSuccess() {
|
||||
Log.d(TAG, "Not yet implemented: onSuccess")
|
||||
failed = false
|
||||
}
|
||||
|
||||
override fun onFailure(reason: Int, description: String?) {
|
||||
Log.d(TAG, "Not yet implemented: onFailure $reason $description")
|
||||
failed = true
|
||||
callback.onWifiSourceFailed()
|
||||
}
|
||||
|
||||
@Deprecated("Not supported on all devices")
|
||||
override fun onPeriodChanged(periodInMs: Int) {
|
||||
Log.d(TAG, "Not yet implemented: onPeriodChanged $periodInMs")
|
||||
}
|
||||
|
||||
override fun onResults(results: Array<out WifiScanner.ScanData>) {
|
||||
callback.onWifiDetailsAvailable(results.flatMap { it.results.toList() }.map(ScanResult::toWifiDetails))
|
||||
}
|
||||
|
||||
override fun onFullResult(fullScanResult: ScanResult) {
|
||||
Log.d(TAG, "Not yet implemented: onFullResult $fullScanResult")
|
||||
}
|
||||
}, workSource)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private var failed = false
|
||||
fun isSupported(context: Context): Boolean {
|
||||
return SDK_INT >= 26 && !failed && (context.getSystemService("wifiscanner") as? WifiScanner) != null && ContextCompat.checkSelfPermission(context, Manifest.permission.LOCATION_HARDWARE) == PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.network.wifi
|
||||
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WifiInfo
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.RequiresApi
|
||||
|
||||
internal fun ScanResult.toWifiDetails(): WifiDetails = WifiDetails(
|
||||
macAddress = BSSID,
|
||||
ssid = SSID,
|
||||
timestamp = if (SDK_INT >= 19) System.currentTimeMillis() - (SystemClock.elapsedRealtime() - (timestamp / 1000)) else null,
|
||||
frequency = frequency,
|
||||
channel = frequencyToChannel(frequency),
|
||||
signalStrength = level,
|
||||
open = setOf("WEP", "WPA", "PSK", "EAP", "IEEE8021X", "PEAP", "TLS", "TTLS").none { capabilities.contains(it) }
|
||||
)
|
||||
|
||||
@RequiresApi(31)
|
||||
internal fun WifiInfo.toWifiDetails(): WifiDetails = WifiDetails(
|
||||
macAddress = bssid,
|
||||
ssid = ssid,
|
||||
timestamp = System.currentTimeMillis(),
|
||||
frequency = frequency,
|
||||
signalStrength = rssi,
|
||||
open = currentSecurityType == WifiInfo.SECURITY_TYPE_OPEN
|
||||
)
|
||||
|
||||
private const val BAND_24_GHZ_FIRST_CH_NUM = 1
|
||||
private const val BAND_24_GHZ_LAST_CH_NUM = 14
|
||||
private const val BAND_5_GHZ_FIRST_CH_NUM = 32
|
||||
private const val BAND_5_GHZ_LAST_CH_NUM = 177
|
||||
private const val BAND_6_GHZ_FIRST_CH_NUM = 1
|
||||
private const val BAND_6_GHZ_LAST_CH_NUM = 233
|
||||
private const val BAND_60_GHZ_FIRST_CH_NUM = 1
|
||||
private const val BAND_60_GHZ_LAST_CH_NUM = 6
|
||||
private const val BAND_24_GHZ_START_FREQ_MHZ = 2412
|
||||
private const val BAND_24_GHZ_END_FREQ_MHZ = 2484
|
||||
private const val BAND_5_GHZ_START_FREQ_MHZ = 5160
|
||||
private const val BAND_5_GHZ_END_FREQ_MHZ = 5885
|
||||
private const val BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ = 5935
|
||||
private const val BAND_6_GHZ_START_FREQ_MHZ = 5955
|
||||
private const val BAND_6_GHZ_END_FREQ_MHZ = 7115
|
||||
private const val BAND_60_GHZ_START_FREQ_MHZ = 58320
|
||||
private const val BAND_60_GHZ_END_FREQ_MHZ = 70200
|
||||
|
||||
internal fun frequencyToChannel(freq: Int): Int? {
|
||||
return when (freq) {
|
||||
// Special cases
|
||||
BAND_24_GHZ_END_FREQ_MHZ -> BAND_24_GHZ_LAST_CH_NUM
|
||||
BAND_6_GHZ_OP_CLASS_136_CH_2_FREQ_MHZ -> 2
|
||||
|
||||
in BAND_24_GHZ_START_FREQ_MHZ..BAND_24_GHZ_END_FREQ_MHZ ->
|
||||
(freq - BAND_24_GHZ_START_FREQ_MHZ) / 5 + BAND_24_GHZ_FIRST_CH_NUM
|
||||
|
||||
in BAND_5_GHZ_START_FREQ_MHZ..BAND_5_GHZ_END_FREQ_MHZ ->
|
||||
(freq - BAND_5_GHZ_START_FREQ_MHZ) / 5 + BAND_5_GHZ_FIRST_CH_NUM
|
||||
|
||||
in BAND_6_GHZ_START_FREQ_MHZ..BAND_6_GHZ_END_FREQ_MHZ ->
|
||||
(freq - BAND_6_GHZ_START_FREQ_MHZ) / 5 + BAND_6_GHZ_FIRST_CH_NUM
|
||||
|
||||
in BAND_60_GHZ_START_FREQ_MHZ..BAND_60_GHZ_END_FREQ_MHZ ->
|
||||
(freq - BAND_60_GHZ_START_FREQ_MHZ) / 2160 + BAND_60_GHZ_FIRST_CH_NUM
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val WifiDetails.isNomap: Boolean
|
||||
get() = ssid?.endsWith("_nomap") == true
|
||||
|
||||
val WifiDetails.isHidden: Boolean
|
||||
get() = ssid == ""
|
||||
|
||||
val WifiDetails.isRequestable: Boolean
|
||||
get() = !isNomap && !isHidden && !isMoving
|
||||
|
||||
val WifiDetails.macBytes: ByteArray
|
||||
get() {
|
||||
val mac = macClean
|
||||
return byteArrayOf(
|
||||
mac.substring(0, 2).toInt(16).toByte(),
|
||||
mac.substring(2, 4).toInt(16).toByte(),
|
||||
mac.substring(4, 6).toInt(16).toByte(),
|
||||
mac.substring(6, 8).toInt(16).toByte(),
|
||||
mac.substring(8, 10).toInt(16).toByte(),
|
||||
mac.substring(10, 12).toInt(16).toByte()
|
||||
)
|
||||
}
|
||||
val WifiDetails.macClean: String
|
||||
get() = macAddress.lowercase().replace(":", "")
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.location.LocationProvider
|
||||
import android.os.Bundle
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.android.location.provider.LocationProviderBase
|
||||
import com.android.location.provider.ProviderPropertiesUnbundled
|
||||
import java.io.FileDescriptor
|
||||
import java.io.PrintWriter
|
||||
|
||||
abstract class AbstractLocationProviderPreTiramisu : LocationProviderBase, GenericLocationProvider {
|
||||
@Deprecated("Use only with SDK < 31")
|
||||
constructor(properties: ProviderPropertiesUnbundled) : super(TAG, properties)
|
||||
|
||||
@RequiresApi(31)
|
||||
constructor(context: Context, properties: ProviderPropertiesUnbundled) : super(context, TAG, properties)
|
||||
|
||||
private var statusUpdateTime = SystemClock.elapsedRealtime()
|
||||
|
||||
override fun onDump(fd: FileDescriptor, pw: PrintWriter, args: Array<out String>) {
|
||||
dump(pw)
|
||||
}
|
||||
|
||||
override fun dump(writer: PrintWriter) {
|
||||
// Nothing by default
|
||||
}
|
||||
|
||||
override fun onFlush(callback: OnFlushCompleteCallback?) {
|
||||
Log.d(TAG, "onFlush")
|
||||
callback!!.onFlushComplete()
|
||||
}
|
||||
|
||||
override fun onSendExtraCommand(command: String?, extras: Bundle?): Boolean {
|
||||
Log.d(TAG, "onSendExtraCommand $command $extras")
|
||||
return false
|
||||
}
|
||||
|
||||
@Deprecated("Overriding this is required pre-Q, but not used since Q")
|
||||
override fun onEnable() {
|
||||
Log.d(TAG, "onEnable")
|
||||
statusUpdateTime = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
@Deprecated("Overriding this is required pre-Q, but not used since Q")
|
||||
override fun onDisable() {
|
||||
Log.d(TAG, "onDisable")
|
||||
statusUpdateTime = SystemClock.elapsedRealtime()
|
||||
}
|
||||
|
||||
@Deprecated("Overriding this is required pre-Q, but not used since Q")
|
||||
override fun onGetStatus(extras: Bundle?): Int {
|
||||
Log.d(TAG, "onGetStatus $extras")
|
||||
return LocationProvider.AVAILABLE
|
||||
}
|
||||
|
||||
|
||||
@Deprecated("Overriding this is required pre-Q, but not used since Q")
|
||||
override fun onGetStatusUpdateTime(): Long {
|
||||
Log.d(TAG, "onGetStatusUpdateTime")
|
||||
return statusUpdateTime
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.location.Criteria
|
||||
import android.location.Location
|
||||
import android.os.Binder
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.util.Log
|
||||
import androidx.core.location.LocationRequestCompat
|
||||
import com.android.location.provider.ProviderPropertiesUnbundled
|
||||
import com.android.location.provider.ProviderRequestUnbundled
|
||||
import com.google.android.gms.location.LocationRequest
|
||||
import com.google.android.gms.location.LocationResult
|
||||
import com.google.android.gms.location.LocationServices
|
||||
import com.google.android.gms.location.Priority
|
||||
import kotlin.math.max
|
||||
|
||||
class FusedLocationProviderService : IntentLocationProviderService() {
|
||||
override fun extractLocation(intent: Intent): Location? = LocationResult.extractResult(intent)?.lastLocation
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
override fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent) {
|
||||
val intervalMillis = max(currentRequest?.interval ?: Long.MAX_VALUE, minIntervalMillis)
|
||||
val request = LocationRequest.Builder(intervalMillis)
|
||||
if (SDK_INT >= 31 && currentRequest != null) {
|
||||
request.setPriority(when {
|
||||
currentRequest.interval == LocationRequestCompat.PASSIVE_INTERVAL -> Priority.PRIORITY_PASSIVE
|
||||
currentRequest.isLowPower -> Priority.PRIORITY_LOW_POWER
|
||||
currentRequest.quality == LocationRequestCompat.QUALITY_LOW_POWER -> Priority.PRIORITY_LOW_POWER
|
||||
currentRequest.quality == LocationRequestCompat.QUALITY_HIGH_ACCURACY -> Priority.PRIORITY_HIGH_ACCURACY
|
||||
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
||||
})
|
||||
request.setMaxUpdateDelayMillis(currentRequest.maxUpdateDelayMillis)
|
||||
request.setWorkSource(currentRequest.workSource)
|
||||
} else {
|
||||
request.setPriority(when {
|
||||
currentRequest?.interval == LocationRequestCompat.PASSIVE_INTERVAL -> Priority.PRIORITY_PASSIVE
|
||||
else -> Priority.PRIORITY_BALANCED_POWER_ACCURACY
|
||||
})
|
||||
}
|
||||
if (SDK_INT >= 29 && currentRequest != null) {
|
||||
request.setBypass(currentRequest.isLocationSettingsIgnored)
|
||||
}
|
||||
val identity = Binder.clearCallingIdentity()
|
||||
try {
|
||||
LocationServices.getFusedLocationProviderClient(this).requestLocationUpdates(request.build(), pendingIntent)
|
||||
} catch (e: SecurityException) {
|
||||
Log.d(TAG, "Failed requesting location updated", e)
|
||||
}
|
||||
Binder.restoreCallingIdentity(identity)
|
||||
}
|
||||
|
||||
override fun stopIntentUpdated(pendingIntent: PendingIntent) {
|
||||
LocationServices.getFusedLocationProviderClient(this).removeLocationUpdates(pendingIntent)
|
||||
}
|
||||
|
||||
override val minIntervalMillis: Long
|
||||
get() = MIN_INTERVAL_MILLIS
|
||||
override val minReportMillis: Long
|
||||
get() = MIN_REPORT_MILLIS
|
||||
override val properties: ProviderPropertiesUnbundled
|
||||
get() = PROPERTIES
|
||||
override val providerName: String
|
||||
get() = "fused"
|
||||
|
||||
companion object {
|
||||
private const val MIN_INTERVAL_MILLIS = 1000L
|
||||
private const val MIN_REPORT_MILLIS = 1000L
|
||||
private val PROPERTIES = ProviderPropertiesUnbundled.create(false, false, false, false, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.location.Location
|
||||
import android.os.IBinder
|
||||
import java.io.PrintWriter
|
||||
|
||||
interface GenericLocationProvider {
|
||||
fun getBinder(): IBinder
|
||||
fun enable()
|
||||
fun disable()
|
||||
fun dump(writer: PrintWriter)
|
||||
fun reportLocationToSystem(location: Location)
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import java.io.FileDescriptor
|
||||
import java.io.PrintWriter
|
||||
|
||||
class GeocodeProviderService : Service() {
|
||||
private var bound: Boolean = false
|
||||
private var provider: OpenStreetMapNominatimGeocodeProvider? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
if (provider == null) {
|
||||
provider = OpenStreetMapNominatimGeocodeProvider(this)
|
||||
}
|
||||
bound = true
|
||||
return provider?.binder
|
||||
}
|
||||
|
||||
override fun dump(fd: FileDescriptor?, writer: PrintWriter?, args: Array<out String>?) {
|
||||
provider?.dump(writer)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.os.WorkSource
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import com.android.location.provider.ProviderPropertiesUnbundled
|
||||
import com.android.location.provider.ProviderRequestUnbundled
|
||||
import org.microg.gms.location.elapsedMillis
|
||||
import org.microg.gms.location.formatRealtime
|
||||
import java.io.PrintWriter
|
||||
import kotlin.math.max
|
||||
|
||||
class IntentLocationProviderPreTiramisu : AbstractLocationProviderPreTiramisu {
|
||||
@Deprecated("Use only with SDK < 31")
|
||||
constructor(service: IntentLocationProviderService, properties: ProviderPropertiesUnbundled, legacy: Unit) : super(properties) {
|
||||
this.service = service
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
constructor(service: IntentLocationProviderService, properties: ProviderPropertiesUnbundled) : super(service, properties) {
|
||||
this.service = service
|
||||
}
|
||||
|
||||
private val service: IntentLocationProviderService
|
||||
private var enabled = false
|
||||
private var currentRequest: ProviderRequestUnbundled? = null
|
||||
private var pendingIntent: PendingIntent? = null
|
||||
private var lastReportedLocation: Location? = null
|
||||
private var lastReportTime: Long = 0
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val reportAgainRunnable = Runnable { reportAgain() }
|
||||
|
||||
private fun updateRequest() {
|
||||
if (enabled && pendingIntent != null) {
|
||||
service.requestIntentUpdated(currentRequest, pendingIntent!!)
|
||||
reportAgain()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dump(writer: PrintWriter) {
|
||||
writer.println("Enabled: $enabled")
|
||||
writer.println("Current request: $currentRequest")
|
||||
if (SDK_INT >= 31) writer.println("Current work source: ${currentRequest?.workSource}")
|
||||
writer.println("Last reported: $lastReportedLocation")
|
||||
writer.println("Last report time: ${lastReportTime.formatRealtime()}")
|
||||
}
|
||||
|
||||
override fun onSetRequest(request: ProviderRequestUnbundled, source: WorkSource) {
|
||||
synchronized(this) {
|
||||
currentRequest = request
|
||||
updateRequest()
|
||||
}
|
||||
}
|
||||
|
||||
override fun enable() {
|
||||
synchronized(this) {
|
||||
if (enabled) throw IllegalStateException()
|
||||
val intent = Intent(service, service.javaClass)
|
||||
intent.action = ACTION_REPORT_LOCATION
|
||||
pendingIntent = PendingIntentCompat.getService(service, 0, intent, FLAG_UPDATE_CURRENT, true)
|
||||
currentRequest = null
|
||||
enabled = true
|
||||
when {
|
||||
SDK_INT >= 30 -> isAllowed = true
|
||||
SDK_INT >= 29 -> isEnabled = true
|
||||
}
|
||||
try {
|
||||
if (lastReportedLocation == null) {
|
||||
lastReportedLocation = service.getSystemService<LocationManager>()?.getLastKnownLocation(service.providerName)
|
||||
}
|
||||
} catch (_: SecurityException) {
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun disable() {
|
||||
synchronized(this) {
|
||||
if (!enabled || pendingIntent == null) throw IllegalStateException()
|
||||
service.stopIntentUpdated(pendingIntent!!)
|
||||
pendingIntent?.cancel()
|
||||
pendingIntent = null
|
||||
currentRequest = null
|
||||
enabled = false
|
||||
handler.removeCallbacks(reportAgainRunnable)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportAgain() {
|
||||
// Report location again if it's recent enough
|
||||
lastReportedLocation?.let {
|
||||
if (it.elapsedMillis + max(currentRequest?.interval ?: 0, service.minIntervalMillis) > SystemClock.elapsedRealtime()) {
|
||||
reportLocationToSystem(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun reportLocationToSystem(location: Location) {
|
||||
handler.removeCallbacks(reportAgainRunnable)
|
||||
location.provider = service.providerName
|
||||
lastReportedLocation = location
|
||||
lastReportTime = SystemClock.elapsedRealtime()
|
||||
super.reportLocation(location)
|
||||
val repeatInterval = max(service.minReportMillis, currentRequest?.interval ?: Long.MAX_VALUE)
|
||||
if (repeatInterval < service.minIntervalMillis) {
|
||||
handler.postDelayed(reportAgainRunnable, repeatInterval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.location.Location
|
||||
import android.os.Binder
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import com.android.location.provider.ProviderPropertiesUnbundled
|
||||
import com.android.location.provider.ProviderRequestUnbundled
|
||||
import java.io.FileDescriptor
|
||||
import java.io.PrintWriter
|
||||
|
||||
abstract class IntentLocationProviderService : Service() {
|
||||
private lateinit var handlerThread: HandlerThread
|
||||
private lateinit var handler: Handler
|
||||
private var bound: Boolean = false
|
||||
private var provider: GenericLocationProvider? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
handlerThread = HandlerThread(this.javaClass.simpleName)
|
||||
handlerThread.start()
|
||||
handler = Handler(handlerThread.looper)
|
||||
}
|
||||
|
||||
abstract fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent)
|
||||
|
||||
abstract fun stopIntentUpdated(pendingIntent: PendingIntent)
|
||||
|
||||
abstract fun extractLocation(intent: Intent): Location?
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (Binder.getCallingUid() == Process.myUid() && intent?.action == ACTION_REPORT_LOCATION) {
|
||||
handler.post {
|
||||
val location = extractLocation(intent)
|
||||
if (location != null) {
|
||||
provider?.reportLocationToSystem(location)
|
||||
}
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? {
|
||||
bound = true
|
||||
if (provider == null) {
|
||||
provider = when {
|
||||
// TODO: Migrate to Tiramisu provider. Not yet required thanks to backwards compat
|
||||
// SDK_INT >= 33 ->
|
||||
SDK_INT >= 31 ->
|
||||
IntentLocationProviderPreTiramisu(this, properties)
|
||||
|
||||
else ->
|
||||
@Suppress("DEPRECATION")
|
||||
(IntentLocationProviderPreTiramisu(this, properties, Unit))
|
||||
}
|
||||
provider?.enable()
|
||||
}
|
||||
return provider?.getBinder()
|
||||
}
|
||||
|
||||
override fun dump(fd: FileDescriptor, writer: PrintWriter, args: Array<out String>) {
|
||||
writer.println("Bound: $bound")
|
||||
provider?.dump(writer)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (SDK_INT >= 18) handlerThread.looper.quitSafely()
|
||||
else handlerThread.looper.quit()
|
||||
provider?.disable()
|
||||
provider = null
|
||||
bound = false
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
abstract val minIntervalMillis: Long
|
||||
abstract val minReportMillis: Long
|
||||
abstract val properties: ProviderPropertiesUnbundled
|
||||
abstract val providerName: String
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Intent
|
||||
import android.location.Criteria
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import com.android.location.provider.ProviderPropertiesUnbundled
|
||||
import com.android.location.provider.ProviderRequestUnbundled
|
||||
import org.microg.gms.location.*
|
||||
import org.microg.gms.location.network.LOCATION_EXTRA_PRECISION
|
||||
import kotlin.math.max
|
||||
|
||||
class NetworkLocationProviderService : IntentLocationProviderService() {
|
||||
override fun extractLocation(intent: Intent): Location? = intent.getParcelableExtra<Location?>(EXTRA_LOCATION)?.apply {
|
||||
extras?.remove(LOCATION_EXTRA_PRECISION)
|
||||
}
|
||||
|
||||
override fun requestIntentUpdated(currentRequest: ProviderRequestUnbundled?, pendingIntent: PendingIntent) {
|
||||
val forceNow: Boolean
|
||||
val intervalMillis: Long
|
||||
if (currentRequest?.reportLocation == true) {
|
||||
forceNow = true
|
||||
intervalMillis = max(currentRequest.interval ?: Long.MAX_VALUE, minIntervalMillis)
|
||||
} else {
|
||||
forceNow = false
|
||||
intervalMillis = Long.MAX_VALUE
|
||||
}
|
||||
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
|
||||
intent.`package` = packageName
|
||||
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
|
||||
intent.putExtra(EXTRA_ENABLE, true)
|
||||
intent.putExtra(EXTRA_INTERVAL_MILLIS, intervalMillis)
|
||||
intent.putExtra(EXTRA_FORCE_NOW, forceNow)
|
||||
if (SDK_INT >= 31) {
|
||||
intent.putExtra(EXTRA_LOW_POWER, currentRequest?.isLowPower ?: false)
|
||||
intent.putExtra(EXTRA_WORK_SOURCE, currentRequest?.workSource)
|
||||
}
|
||||
if (SDK_INT >= 29) {
|
||||
intent.putExtra(EXTRA_BYPASS, currentRequest?.isLocationSettingsIgnored ?: false)
|
||||
}
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
override fun stopIntentUpdated(pendingIntent: PendingIntent) {
|
||||
val intent = Intent(ACTION_NETWORK_LOCATION_SERVICE)
|
||||
intent.`package` = packageName
|
||||
intent.putExtra(EXTRA_PENDING_INTENT, pendingIntent)
|
||||
intent.putExtra(EXTRA_ENABLE, false)
|
||||
startService(intent)
|
||||
}
|
||||
|
||||
override val minIntervalMillis: Long
|
||||
get() = MIN_INTERVAL_MILLIS
|
||||
override val minReportMillis: Long
|
||||
get() = MIN_REPORT_MILLIS
|
||||
override val properties: ProviderPropertiesUnbundled
|
||||
get() = PROPERTIES
|
||||
override val providerName: String
|
||||
get() = LocationManager.NETWORK_PROVIDER
|
||||
|
||||
|
||||
companion object {
|
||||
private const val MIN_INTERVAL_MILLIS = 20000L
|
||||
private const val MIN_REPORT_MILLIS = 1000L
|
||||
private val PROPERTIES = ProviderPropertiesUnbundled.create(false, false, false, false, true, true, true, Criteria.POWER_LOW, Criteria.ACCURACY_COARSE)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Address
|
||||
import android.location.GeocoderParams
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.LruCache
|
||||
import com.android.location.provider.GeocodeProvider
|
||||
import com.android.volley.toolbox.JsonArrayRequest
|
||||
import com.android.volley.toolbox.JsonObjectRequest
|
||||
import com.android.volley.toolbox.Volley
|
||||
import com.google.android.gms.location.internal.ClientIdentity
|
||||
import org.json.JSONObject
|
||||
import org.microg.address.Formatter
|
||||
import org.microg.gms.location.LocationSettings
|
||||
import org.microg.gms.utils.singleInstanceOf
|
||||
import java.io.PrintWriter
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
|
||||
class OpenStreetMapNominatimGeocodeProvider(private val context: Context) : GeocodeProvider() {
|
||||
private val queue = singleInstanceOf { Volley.newRequestQueue(context.applicationContext) }
|
||||
private val formatter = runCatching { Formatter() }.getOrNull()
|
||||
private val addressCache = LruCache<CacheKey, Address>(CACHE_SIZE)
|
||||
private val settings by lazy { LocationSettings(context) }
|
||||
|
||||
override fun onGetFromLocation(latitude: Double, longitude: Double, maxResults: Int, params: GeocoderParams, addresses: MutableList<Address>): String? {
|
||||
val clientIdentity = params.clientIdentity ?: return "null client package"
|
||||
val locale = params.locale ?: return "null locale"
|
||||
if (!settings.geocoderNominatim) return "disabled"
|
||||
val cacheKey = CacheKey(clientIdentity, locale, latitude, longitude)
|
||||
addressCache[cacheKey]?.let {address ->
|
||||
addresses.add(address)
|
||||
return null
|
||||
}
|
||||
val uri = Uri.Builder()
|
||||
.scheme("https").authority(NOMINATIM_SERVER).path("/reverse")
|
||||
.appendQueryParameter("format", "json")
|
||||
.appendQueryParameter("accept-language", locale.language)
|
||||
.appendQueryParameter("addressdetails", "1")
|
||||
.appendQueryParameter("lat", latitude.toString())
|
||||
.appendQueryParameter("lon", longitude.toString())
|
||||
val result = AtomicReference<String?>("timeout reached")
|
||||
val returnedAddress = AtomicReference<Address?>(null)
|
||||
val latch = CountDownLatch(1)
|
||||
queue.add(object : JsonObjectRequest(uri.build().toString(), {
|
||||
parseResponse(locale, it)?.let(returnedAddress::set)
|
||||
result.set(null)
|
||||
latch.countDown()
|
||||
}, {
|
||||
result.set(it.message)
|
||||
latch.countDown()
|
||||
}) {
|
||||
override fun getHeaders(): Map<String, String> = mapOf("User-Agent" to "microG/${context.versionName}")
|
||||
})
|
||||
latch.await(5, TimeUnit.SECONDS)
|
||||
val address = returnedAddress.get()
|
||||
if (address != null) {
|
||||
Log.d(TAG, "Returned $address for $latitude,$longitude")
|
||||
addresses.add(address)
|
||||
addressCache.put(cacheKey, address)
|
||||
}
|
||||
return result.get()
|
||||
}
|
||||
|
||||
override fun onGetFromLocationName(
|
||||
locationName: String,
|
||||
lowerLeftLatitude: Double,
|
||||
lowerLeftLongitude: Double,
|
||||
upperRightLatitude: Double,
|
||||
upperRightLongitude: Double,
|
||||
maxResults: Int,
|
||||
params: GeocoderParams,
|
||||
addresses: MutableList<Address>
|
||||
): String? {
|
||||
val clientIdentity = params.clientIdentity ?: return "null client package"
|
||||
val locale = params.locale ?: return "null locale"
|
||||
if (!settings.geocoderNominatim) return "disabled"
|
||||
val uri = Uri.Builder()
|
||||
.scheme("https").authority(NOMINATIM_SERVER).path("/search")
|
||||
.appendQueryParameter("format", "json")
|
||||
.appendQueryParameter("accept-language", locale.language)
|
||||
.appendQueryParameter("addressdetails", "1")
|
||||
.appendQueryParameter("bounded", "1")
|
||||
.appendQueryParameter("q", locationName)
|
||||
.appendQueryParameter("limit", maxResults.toString())
|
||||
if (lowerLeftLatitude != upperRightLatitude && lowerLeftLongitude != upperRightLongitude) {
|
||||
uri.appendQueryParameter("viewbox", "$lowerLeftLongitude,$upperRightLatitude,$upperRightLongitude,$lowerLeftLatitude")
|
||||
}
|
||||
val result = AtomicReference<String?>("timeout reached")
|
||||
val latch = CountDownLatch(1)
|
||||
queue.add(object : JsonArrayRequest(uri.build().toString(), {
|
||||
for (i in 0 until it.length()) {
|
||||
parseResponse(locale, it.getJSONObject(i))?.let(addresses::add)
|
||||
}
|
||||
result.set(null)
|
||||
latch.countDown()
|
||||
}, {
|
||||
result.set(it.message)
|
||||
latch.countDown()
|
||||
}) {
|
||||
override fun getHeaders(): Map<String, String> = mapOf("User-Agent" to "microG/${context.versionName}")
|
||||
})
|
||||
latch.await(5, TimeUnit.SECONDS)
|
||||
return result.get()
|
||||
}
|
||||
|
||||
private fun parseResponse(locale: Locale, result: JSONObject): Address? {
|
||||
if (!result.has(WIRE_LATITUDE) || !result.has(WIRE_LONGITUDE) ||
|
||||
!result.has(WIRE_ADDRESS)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
Log.d(TAG, "Result: $result")
|
||||
val address = Address(locale)
|
||||
address.latitude = result.getDouble(WIRE_LATITUDE)
|
||||
address.longitude = result.getDouble(WIRE_LONGITUDE)
|
||||
val a = result.getJSONObject(WIRE_ADDRESS)
|
||||
address.thoroughfare = a.optString(WIRE_THOROUGHFARE)
|
||||
address.subLocality = a.optString(WIRE_SUBLOCALITY)
|
||||
address.postalCode = a.optString(WIRE_POSTALCODE)
|
||||
address.subAdminArea = a.optString(WIRE_SUBADMINAREA)
|
||||
address.adminArea = a.optString(WIRE_ADMINAREA)
|
||||
address.countryName = a.optString(WIRE_COUNTRYNAME)
|
||||
address.countryCode = a.optString(WIRE_COUNTRYCODE)
|
||||
if (a.has(WIRE_LOCALITY_CITY)) {
|
||||
address.locality = a.getString(WIRE_LOCALITY_CITY)
|
||||
} else if (a.has(WIRE_LOCALITY_TOWN)) {
|
||||
address.locality = a.getString(WIRE_LOCALITY_TOWN)
|
||||
} else if (a.has(WIRE_LOCALITY_VILLAGE)) {
|
||||
address.locality = a.getString(WIRE_LOCALITY_VILLAGE)
|
||||
}
|
||||
if (formatter != null) {
|
||||
val components = mutableMapOf<String, String>()
|
||||
for (s in a.keys()) {
|
||||
if (s !in WIRE_IGNORED) {
|
||||
components[s] = a[s].toString()
|
||||
}
|
||||
}
|
||||
val split = formatter.formatAddress(components).split("\n")
|
||||
for (i in split.indices) {
|
||||
address.setAddressLine(i, split[i])
|
||||
}
|
||||
address.featureName = formatter.guessName(components)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
fun dump(writer: PrintWriter?) {
|
||||
writer?.println("Enabled: ${settings.geocoderNominatim}")
|
||||
writer?.println("Address cache: size=${addressCache.size()} hits=${addressCache.hitCount()} miss=${addressCache.missCount()} puts=${addressCache.putCount()} evicts=${addressCache.evictionCount()}")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CACHE_SIZE = 200
|
||||
|
||||
private const val NOMINATIM_SERVER = "nominatim.openstreetmap.org"
|
||||
|
||||
private const val WIRE_LATITUDE = "lat"
|
||||
private const val WIRE_LONGITUDE = "lon"
|
||||
private const val WIRE_ADDRESS = "address"
|
||||
private const val WIRE_THOROUGHFARE = "road"
|
||||
private const val WIRE_SUBLOCALITY = "suburb"
|
||||
private const val WIRE_POSTALCODE = "postcode"
|
||||
private const val WIRE_LOCALITY_CITY = "city"
|
||||
private const val WIRE_LOCALITY_TOWN = "town"
|
||||
private const val WIRE_LOCALITY_VILLAGE = "village"
|
||||
private const val WIRE_SUBADMINAREA = "county"
|
||||
private const val WIRE_ADMINAREA = "state"
|
||||
private const val WIRE_COUNTRYNAME = "country"
|
||||
private const val WIRE_COUNTRYCODE = "country_code"
|
||||
|
||||
private val WIRE_IGNORED = setOf<String>("ISO3166-2-lvl4")
|
||||
|
||||
private data class CacheKey(val uid: Int, val packageName: String?, val locale: Locale, val latitude: Int, val longitude: Int) {
|
||||
constructor(clientIdentity: ClientIdentity, locale: Locale, latitude: Double, longitude: Double) : this(clientIdentity.uid, clientIdentity.packageName.takeIf { clientIdentity.uid != 0 }, locale, (latitude * 100000.0).toInt(), (longitude * 100000.0).toInt())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 microG Project Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
package org.microg.gms.location.provider
|
||||
|
||||
import android.content.Context
|
||||
import android.location.GeocoderParams
|
||||
import android.os.Build.VERSION.SDK_INT
|
||||
import com.google.android.gms.location.internal.ClientIdentity
|
||||
|
||||
const val TAG = "LocationProvider"
|
||||
|
||||
const val ACTION_REPORT_LOCATION = "org.microg.gms.location.provider.ACTION_REPORT_LOCATION"
|
||||
|
||||
val GeocoderParams.clientIdentity: ClientIdentity?
|
||||
get() = clientPackage?.let {
|
||||
ClientIdentity(it).apply {
|
||||
if (SDK_INT >= 33) {
|
||||
uid = clientUid
|
||||
attributionTag = clientAttributionTag
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val Context.versionName: String?
|
||||
get() = packageManager.getPackageInfo(packageName, 0).versionName
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ SPDX-FileCopyrightText: 2024 microG Project Team
|
||||
~ SPDX-License-Identifier: Apache-2.0
|
||||
-->
|
||||
<paths>
|
||||
<cache-path name="location" path="location" />
|
||||
</paths>
|
||||
Loading…
Add table
Add a link
Reference in a new issue