diff --git a/ui/build.gradle b/ui/build.gradle
new file mode 100644
index 0000000..f017ac9
--- /dev/null
+++ b/ui/build.gradle
@@ -0,0 +1,83 @@
+/*
+ * SPDX-FileCopyrightText: 2019, microG Project Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'kotlin-kapt'
+apply plugin: 'kotlin-android-extensions'
+apply plugin: 'maven-publish'
+
+android {
+ compileSdkVersion androidCompileSdk
+ buildToolsVersion "$androidBuildVersionTools"
+ dataBinding {
+ enabled = true
+ }
+
+ defaultConfig {
+ versionName version
+ minSdkVersion Math.max(androidMinSdk, 14)
+ targetSdkVersion androidTargetSdk
+ }
+
+ sourceSets {
+ main.java.srcDirs += 'src/main/kotlin'
+ }
+
+ compileOptions {
+ sourceCompatibility = 1.8
+ targetCompatibility = 1.8
+ }
+}
+
+dependencies {
+ implementation project(':api')
+ implementation project(':client')
+
+ implementation "androidx.appcompat:appcompat:$appcompatVersion"
+ implementation "androidx.fragment:fragment:$fragmentVersion"
+ implementation "androidx.recyclerview:recyclerview:$recyclerviewVersion"
+ implementation "androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion"
+ implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion"
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
+}
+
+afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ pom {
+ name = 'UnifiedNlp UI'
+ description = 'UnifiedNlp UI library for common configuration fragments'
+ url = 'https://github.com/microg/UnifiedNlp'
+ licenses {
+ license {
+ name = 'The Apache Software License, Version 2.0'
+ url = 'http://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'microg'
+ name = 'microG Team'
+ }
+ developer {
+ id = 'mar-v-in'
+ name = 'Marvin W.'
+ }
+ }
+ scm {
+ url = 'https://github.com/microg/UnifiedNlp'
+ connection = 'scm:git:https://github.com/microg/UnifiedNlp.git'
+ developerConnection = 'scm:git:ssh://github.com/microg/UnifiedNlp.git'
+ }
+ }
+
+ from components.release
+ }
+ }
+ }
+}
diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e1dc495
--- /dev/null
+++ b/ui/src/main/AndroidManifest.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
diff --git a/ui/src/main/kotlin/org/microg/nlp/ui/BackendDetailsFragment.kt b/ui/src/main/kotlin/org/microg/nlp/ui/BackendDetailsFragment.kt
new file mode 100644
index 0000000..0146ee4
--- /dev/null
+++ b/ui/src/main/kotlin/org/microg/nlp/ui/BackendDetailsFragment.kt
@@ -0,0 +1,150 @@
+package org.microg.nlp.ui
+
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.ACTION_VIEW
+import android.content.pm.PackageManager.GET_META_DATA
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.os.Bundle
+import android.util.TypedValue
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.annotation.AttrRes
+import androidx.annotation.ColorInt
+import androidx.core.content.ContextCompat
+import androidx.databinding.Observable
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import kotlinx.coroutines.launch
+import org.microg.nlp.client.UnifiedLocationClient
+import org.microg.nlp.ui.BackendType.GEOCODER
+import org.microg.nlp.ui.BackendType.LOCATION
+import org.microg.nlp.ui.databinding.BackendDetailsBinding
+import java.util.*
+
+class BackendDetailsFragment : Fragment(R.layout.backend_details) {
+
+ fun Double.toStringWithDigits(digits: Int): String {
+ val s = this.toString()
+ val i = s.indexOf('.')
+ if (i <= 0 || s.length - i - 1 < digits) return s
+ if (digits == 0) return s.substring(0, i)
+ return s.substring(0, s.indexOf('.') + digits + 1)
+ }
+
+ fun Float.toStringWithDigits(digits: Int): String {
+ val s = this.toString()
+ val i = s.indexOf('.')
+ if (i <= 0 || s.length - i - 1 < digits) return s
+ if (digits == 0) return s.substring(0, i)
+ return s.substring(0, s.indexOf('.') + digits + 1)
+ }
+
+ @ColorInt
+ fun Context.resolveColor(@AttrRes resid: Int): Int? {
+ val typedValue = TypedValue()
+ if (!theme.resolveAttribute(resid, typedValue, true)) return null
+ val colorRes = if (typedValue.resourceId != 0) typedValue.resourceId else typedValue.data
+ return ContextCompat.getColor(this, colorRes)
+ }
+
+ val switchBarEnabledColor: Int
+ get() = context?.resolveColor(androidx.appcompat.R.attr.colorControlActivated) ?: Color.RED
+
+ val switchBarDisabledColor: Int
+ get() {
+ val color = context?.resolveColor(android.R.attr.textColorSecondary) ?: Color.RED
+ return Color.argb(100, Color.red(color), Color.green(color), Color.blue(color))
+ }
+
+ val switchBarTrackTintColor: ColorStateList
+ get() {
+ val color = context?.resolveColor(android.R.attr.textColorPrimaryInverse)
+ ?: Color.RED
+ val withAlpha = Color.argb(50, Color.red(color), Color.green(color), Color.blue(color))
+ return ColorStateList(arrayOf(emptyArray().toIntArray()), arrayOf(withAlpha).toIntArray())
+ }
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ val binding = BackendDetailsBinding.inflate(inflater, container, false)
+ binding.fragment = this
+ binding.switchWidget.trackTintList = switchBarTrackTintColor
+ lifecycleScope.launchWhenStarted {
+ val entry = createBackendInfo()
+ binding.entry = entry
+ binding.executePendingBindings()
+ if (entry?.type == LOCATION) {
+ val client = UnifiedLocationClient[entry.context]
+
+ val location = client.getLastLocationForBackend(entry.serviceInfo.packageName, entry.serviceInfo.name, entry.firstSignatureDigest)
+ ?: return@launchWhenStarted
+ var locationString = "${location.latitude.toStringWithDigits(6)}, ${location.longitude.toStringWithDigits(6)}"
+
+ val address = client.getFromLocation(location.latitude, location.longitude, 1, Locale.getDefault().toString()).singleOrNull()
+ if (address != null) {
+ val addressLine = StringBuilder()
+ var i = 0
+ addressLine.append(address.getAddressLine(i))
+ while (addressLine.length < 10 && address.maxAddressLineIndex > i) {
+ i++
+ addressLine.append(", ")
+ addressLine.append(address.getAddressLine(i))
+ }
+ locationString = addressLine.toString()
+ }
+ binding.lastLocationString = locationString
+ binding.executePendingBindings()
+ }
+ }
+ return binding.root
+ }
+
+ fun onBackendEnabledChanged(entry: BackendInfo) {
+ entry.enabled = !entry.enabled
+ }
+
+ private fun createExternalIntent(packageName: String, activityName: String): Intent {
+ val intent = Intent(ACTION_VIEW);
+ intent.setPackage(packageName);
+ intent.setClassName(packageName, activityName);
+ return intent;
+ }
+
+ private fun startExternalActivity(packageName: String, activityName: String) {
+ requireContext().startActivity(createExternalIntent(packageName, activityName))
+ }
+
+ fun onAboutClicked(entry: BackendInfo) {
+ entry.aboutActivity?.let { activityName -> startExternalActivity(entry.serviceInfo.packageName, activityName) }
+ }
+
+ fun onConfigureClicked(entry: BackendInfo) {
+ entry.settingsActivity?.let { activityName -> startExternalActivity(entry.serviceInfo.packageName, activityName) }
+ }
+
+ private suspend fun createBackendInfo(): BackendInfo? {
+ val activity = activity ?: return null
+ val intent = activity.intent ?: return null
+ val type = BackendType.values().find { it.name == intent.getStringExtra(EXTRA_TYPE) }
+ ?: return null
+ val packageName = intent.getStringExtra(EXTRA_PACKAGE) ?: return null
+ val name = intent.getStringExtra(EXTRA_NAME) ?: return null
+ val serviceInfo = activity.packageManager.getServiceInfo(ComponentName(packageName, name), GET_META_DATA)
+ ?: return null
+ val enabledBackends = when (type) {
+ GEOCODER -> UnifiedLocationClient[activity].getGeocoderBackends()
+ LOCATION -> UnifiedLocationClient[activity].getLocationBackends()
+ }
+ return BackendInfo(activity, serviceInfo, type, lifecycleScope, enabledBackends)
+ }
+
+ companion object {
+ val ACTION = "org.microg.nlp.ui.BACKEND_DETAILS"
+ val EXTRA_TYPE = "org.microg.nlp.ui.BackendDetailsFragment.type"
+ val EXTRA_PACKAGE = "org.microg.nlp.ui.BackendDetailsFragment.package"
+ val EXTRA_NAME = "org.microg.nlp.ui.BackendDetailsFragment.name"
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/kotlin/org/microg/nlp/ui/BackendInfo.kt b/ui/src/main/kotlin/org/microg/nlp/ui/BackendInfo.kt
new file mode 100644
index 0000000..f4b4db8
--- /dev/null
+++ b/ui/src/main/kotlin/org/microg/nlp/ui/BackendInfo.kt
@@ -0,0 +1,95 @@
+package org.microg.nlp.ui
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.pm.PackageInfo
+import android.content.pm.PackageManager
+import android.content.pm.ServiceInfo
+import android.graphics.drawable.Drawable
+import android.util.Log
+import androidx.databinding.BaseObservable
+import androidx.databinding.Bindable
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import org.microg.nlp.api.Constants
+import org.microg.nlp.client.UnifiedLocationClient
+import java.security.MessageDigest
+import java.security.NoSuchAlgorithmException
+
+private val TAG: String = "ULocUI"
+
+class BackendInfo(val context: Context, val serviceInfo: ServiceInfo, val type: BackendType, val coroutineScope: CoroutineScope, enabledBackends: Array) : BaseObservable() {
+ val firstSignatureDigest = firstSignatureDigest(context, serviceInfo.packageName)
+ val unsignedComponent: String = "${serviceInfo.packageName}/${serviceInfo.name}"
+ val signedComponent: String = "${serviceInfo.packageName}/${serviceInfo.name}/$firstSignatureDigest"
+
+ var enabled: Boolean = enabledBackends.contains(signedComponent) || enabledBackends.contains(unsignedComponent)
+ @Bindable get
+ set(value) {
+ if (field == value) return
+ field = value
+ notifyPropertyChanged(BR.enabled)
+ coroutineScope.launch {
+ val client = UnifiedLocationClient[context]
+ val withoutSelf = when (type) {
+ BackendType.LOCATION -> client.getLocationBackends()
+ BackendType.GEOCODER -> client.getGeocoderBackends()
+ }.filterNot { it == unsignedComponent || it.startsWith("$unsignedComponent/") }.toTypedArray()
+ val new = if (value) withoutSelf + signedComponent else withoutSelf
+ try {
+ when (type) {
+ BackendType.LOCATION -> client.setLocationBackends(new)
+ BackendType.GEOCODER -> client.setGeocoderBackends(new)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to change backend state", e)
+ field = !value
+ notifyPropertyChanged(BR.enabled)
+ }
+ }
+ }
+
+ val appIcon: Drawable by lazy { serviceInfo.loadIcon(context.packageManager) }
+ val name: CharSequence by lazy { serviceInfo.loadLabel(context.packageManager) }
+ val appName: CharSequence by lazy { serviceInfo.applicationInfo.loadLabel(context.packageManager) }
+
+ val backendSummary: String? by lazy { serviceInfo.metaData?.getString(Constants.METADATA_BACKEND_SUMMARY) }
+ val settingsActivity: String? by lazy { serviceInfo.metaData?.getString(Constants.METADATA_BACKEND_SETTINGS_ACTIVITY) }
+ val aboutActivity: String? by lazy { serviceInfo.metaData?.getString(Constants.METADATA_BACKEND_ABOUT_ACTIVITY) }
+
+}
+
+enum class BackendType { LOCATION, GEOCODER }
+
+@Suppress("DEPRECATION")
+@SuppressLint("PackageManagerGetSignatures")
+fun firstSignatureDigest(context: Context, packageName: String?): String? {
+ val packageManager = context.packageManager
+ val info: PackageInfo?
+ try {
+ info = packageManager.getPackageInfo(packageName!!, PackageManager.GET_SIGNATURES)
+ } catch (e: PackageManager.NameNotFoundException) {
+ return null
+ }
+
+ if (info?.signatures?.isNotEmpty() == true) {
+ for (sig in info.signatures) {
+ sha256sum(sig.toByteArray())?.let { return it }
+ }
+ }
+ return null
+}
+
+private fun sha256sum(bytes: ByteArray): String? {
+ try {
+ val md = MessageDigest.getInstance("SHA-256")
+ val digest = md.digest(bytes)
+ val sb = StringBuilder(2 * digest.size)
+ for (b in digest) {
+ sb.append(String.format("%02x", b))
+ }
+ return sb.toString()
+ } catch (e: NoSuchAlgorithmException) {
+ return null
+ }
+}
\ No newline at end of file
diff --git a/ui/src/main/kotlin/org/microg/nlp/ui/BackendListFragment.kt b/ui/src/main/kotlin/org/microg/nlp/ui/BackendListFragment.kt
new file mode 100644
index 0000000..1feb48b
--- /dev/null
+++ b/ui/src/main/kotlin/org/microg/nlp/ui/BackendListFragment.kt
@@ -0,0 +1,86 @@
+package org.microg.nlp.ui
+
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.content.pm.PackageManager.GET_META_DATA
+import android.os.Bundle
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
+import androidx.recyclerview.widget.RecyclerView
+import org.microg.nlp.api.Constants.*
+import org.microg.nlp.client.UnifiedLocationClient
+import org.microg.nlp.ui.BackendDetailsFragment.Companion.EXTRA_NAME
+import org.microg.nlp.ui.BackendDetailsFragment.Companion.EXTRA_PACKAGE
+import org.microg.nlp.ui.BackendDetailsFragment.Companion.EXTRA_TYPE
+import org.microg.nlp.ui.databinding.BackendListBinding
+import org.microg.nlp.ui.databinding.BackendListEntryBinding
+
+class BackendListFragment : Fragment(R.layout.backend_list) {
+ val locationAdapter: BackendSettingsLineAdapter = BackendSettingsLineAdapter(this)
+ val geocoderAdapter: BackendSettingsLineAdapter = BackendSettingsLineAdapter(this)
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
+ val binding = BackendListBinding.inflate(inflater, container, false)
+ binding.fragment = this
+ lifecycleScope.launchWhenStarted { updateAdapters() }
+ return binding.root
+ }
+
+ fun onBackendSelected(entry: BackendInfo) {
+ val intent = Intent(BackendDetailsFragment.ACTION)
+ //intent.`package` = requireContext().packageName
+ intent.putExtra(EXTRA_TYPE, entry.type.name)
+ intent.putExtra(EXTRA_PACKAGE, entry.serviceInfo.packageName)
+ intent.putExtra(EXTRA_NAME, entry.serviceInfo.name)
+ context?.packageManager?.queryIntentActivities(intent, 0)?.forEach {
+ Log.d("USettings", it.activityInfo.name)
+ }
+ startActivity(intent)
+ }
+
+ private suspend fun updateAdapters() {
+ val context = requireContext()
+ locationAdapter.entries = createBackendInfoList(context, Intent(ACTION_LOCATION_BACKEND), UnifiedLocationClient[context].getLocationBackends(), BackendType.LOCATION)
+ geocoderAdapter.entries = createBackendInfoList(context, Intent(ACTION_GEOCODER_BACKEND), UnifiedLocationClient[context].getGeocoderBackends(), BackendType.GEOCODER)
+ }
+
+ private fun createBackendInfoList(context: Context, intent: Intent, enabledBackends: Array, type: BackendType): Array {
+ val backends = context.packageManager.queryIntentServices(intent, GET_META_DATA).map { BackendInfo(context, it.serviceInfo, type, lifecycleScope, enabledBackends) }
+ return backends.toTypedArray()
+ }
+}
+
+class BackendSettingsLineViewHolder(val binding: BackendListEntryBinding) : RecyclerView.ViewHolder(binding.root) {
+ fun bind(fragment: BackendListFragment, entry: BackendInfo) {
+ binding.fragment = fragment
+ binding.entry = entry
+ binding.executePendingBindings()
+ }
+}
+
+class BackendSettingsLineAdapter(val fragment: BackendListFragment) : RecyclerView.Adapter() {
+ var entries: Array = emptyArray()
+ set(value) {
+ field = value
+ notifyDataSetChanged()
+ }
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BackendSettingsLineViewHolder {
+ return BackendSettingsLineViewHolder(BackendListEntryBinding.inflate(LayoutInflater.from(parent.context), parent, false))
+ }
+
+ override fun onBindViewHolder(holder: BackendSettingsLineViewHolder, position: Int) {
+ holder.bind(fragment, entries[position])
+ }
+
+ override fun getItemCount(): Int {
+ return entries.size
+ }
+}
+
+
diff --git a/ui/src/main/res/layout/backend_details.xml b/ui/src/main/res/layout/backend_details.xml
new file mode 100644
index 0000000..7d8bfc3
--- /dev/null
+++ b/ui/src/main/res/layout/backend_details.xml
@@ -0,0 +1,301 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/src/main/res/layout/backend_list.xml b/ui/src/main/res/layout/backend_list.xml
new file mode 100644
index 0000000..2bd1961
--- /dev/null
+++ b/ui/src/main/res/layout/backend_list.xml
@@ -0,0 +1,101 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/src/main/res/layout/backend_list_entry.xml b/ui/src/main/res/layout/backend_list_entry.xml
new file mode 100644
index 0000000..0912279
--- /dev/null
+++ b/ui/src/main/res/layout/backend_list_entry.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ui/src/main/res/values-de/strings.xml b/ui/src/main/res/values-de/strings.xml
new file mode 100644
index 0000000..9aeee18
--- /dev/null
+++ b/ui/src/main/res/values-de/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Funknetz-basierte Ortung
+ Adressauflösung
+
diff --git a/ui/src/main/res/values-eo/strings.xml b/ui/src/main/res/values-eo/strings.xml
new file mode 100644
index 0000000..024a2b9
--- /dev/null
+++ b/ui/src/main/res/values-eo/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Ret-bazita pozici-trovado
+ Adres-elserĉado
+
diff --git a/ui/src/main/res/values-fr/strings.xml b/ui/src/main/res/values-fr/strings.xml
new file mode 100644
index 0000000..4c2f79c
--- /dev/null
+++ b/ui/src/main/res/values-fr/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Géolocalisation réseau
+ Géocodage
+
diff --git a/ui/src/main/res/values-pl/strings.xml b/ui/src/main/res/values-pl/strings.xml
new file mode 100644
index 0000000..afc75a6
--- /dev/null
+++ b/ui/src/main/res/values-pl/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Geolokalizacja oparta o sieć
+ Wyszukiwanie adresów
+
diff --git a/ui/src/main/res/values-ro/strings.xml b/ui/src/main/res/values-ro/strings.xml
new file mode 100644
index 0000000..9a36da2
--- /dev/null
+++ b/ui/src/main/res/values-ro/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Localizare geografică bazată pe rețea
+ Căutare adrese
+
diff --git a/ui/src/main/res/values-ru/strings.xml b/ui/src/main/res/values-ru/strings.xml
new file mode 100644
index 0000000..0d8c36e
--- /dev/null
+++ b/ui/src/main/res/values-ru/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Местоположение по сети
+ Поиск адреса
+
diff --git a/ui/src/main/res/values-sr/strings.xml b/ui/src/main/res/values-sr/strings.xml
new file mode 100644
index 0000000..0f1c983
--- /dev/null
+++ b/ui/src/main/res/values-sr/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Мрежна геолокација
+ Потрага адресе
+
diff --git a/ui/src/main/res/values-uk/strings.xml b/ui/src/main/res/values-uk/strings.xml
new file mode 100644
index 0000000..38269e8
--- /dev/null
+++ b/ui/src/main/res/values-uk/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Географічне позиціювання на основі мереж
+ Пошук адрес
+
diff --git a/ui/src/main/res/values-zh-rTW/strings.xml b/ui/src/main/res/values-zh-rTW/strings.xml
new file mode 100644
index 0000000..cd57661
--- /dev/null
+++ b/ui/src/main/res/values-zh-rTW/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ 基於網路的地理位置定位
+ 地址查閱
+
diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml
new file mode 100644
index 0000000..9bab67b
--- /dev/null
+++ b/ui/src/main/res/values/strings.xml
@@ -0,0 +1,10 @@
+
+
+
+
+ Network-based Geolocation
+ Address lookup
+