Translate the Android app to German

The texts move to string resources with a German copy, so the app
follows the phone language. Android 13+ also offers the app's own
language setting. Lint now flags hard-coded texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitb105e37fbd943bb96bde9381c88c73eb1dacd7b0
Parentb8a8f76
10 files changed, 216 insertions(+), 69 deletions(-)
▾MREADME.md
@@ -33,6 +33,10 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
Debug builds allow plain HTTP. In the emulator, the server on the host is `http://10.0.2.2:8080`. `adb emu geo fix <lon> <lat>` moves the emulator's GPS.
### Languages
The web UI and the app are in English and German. The web UI follows the browser language, the app the phone language or its own language setting in Android. Web texts live in `web/src/i18n.rs`, with the English text as the key. App texts live in `android/app/src/main/res/values*/strings.xml`.
## Configuration
Every flag can also be set by its environment variable. `otserver --help` lists them.
▾Mandroid/app/build.gradle.kts
@@ -35,8 +35,6 @@ android {
lint {
disable += setOf(
// Not translated. The texts live next to the code that uses them.
"SetTextI18n",
// Upgrades are a decision, not a lint finding.
"OldTargetApi",
"GradleDependency",
▾Mandroid/app/src/main/AndroidManifest.xml
@@ -25,7 +25,8 @@
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@mipmap/ic_launcher"
android:label="opentracker"
android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:supportsRtl="true"
android:theme="@android:style/Theme.DeviceDefault.DayNight"
android:usesCleartextTraffic="${cleartext}">
▾Mandroid/app/src/main/java/org/opentracker/MainActivity.kt
@@ -98,7 +98,7 @@ class MainActivity : Activity() {
private fun setupView(): View {
val url = EditText(this).apply {
hint = "https://track.example.com"
setHint(R.string.url_hint)
inputType = InputType.TYPE_TEXT_VARIATION_URI
setText(prefs.url ?: "")
}
@@ -111,20 +111,20 @@ class MainActivity : Activity() {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), dp(24), dp(24), dp(24))
addView(TextView(this@MainActivity).apply {
text = "Connect to your server"
setText(R.string.setup_title)
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
})
addView(TextView(this@MainActivity).apply {
text = "Enter the address of your opentracker server. You then sign in in the browser and confirm this phone."
setText(R.string.setup_text)
setPadding(0, dp(8), 0, dp(16))
})
addView(url)
addView(Button(this@MainActivity).apply {
text = "Connect in browser"
setText(R.string.connect_in_browser)
setOnClickListener { pair(url.text.toString()) }
})
addView(Button(this@MainActivity).apply {
text = "Paste a device token"
setText(R.string.paste_token)
setOnClickListener { pasteToken(url.text.toString()) }
})
addView(error)
@@ -142,14 +142,14 @@ class MainActivity : Activity() {
}
val statusView = TextView(this).apply { setOnClickListener { if (missingPermissions()) showPermissions() } }
val toggle = Switch(this).apply {
text = "Tracking"
setText(R.string.tracking)
// A click, not a checked-change listener: showStatus also sets the switch, and that must not start or stop anything.
setOnClickListener { if (isChecked) startTracking() else stopTracking() }
}
val more = ImageButton(this).apply {
setImageResource(R.drawable.ic_more)
background = null
contentDescription = "More"
contentDescription = getString(R.string.more)
setOnClickListener { showMenu(it) }
}
bar.addView(statusView, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))
@@ -194,36 +194,36 @@ class MainActivity : Activity() {
val view = status ?: return
val last = prefs.lastUploadMs
view.text = when {
!prefs.tracking -> "Tracking is off"
!prefs.tracking -> getString(R.string.tracking_off)
else -> listOfNotNull(
prefs.mode,
if (last > 0) "sent ${DateFormat.getTimeFormat(this).format(last)}" else "nothing sent yet",
prefs.queued.takeIf { it > 0 }?.let { "$it waiting" },
prefs.modeLabel(this),
if (last > 0) getString(R.string.sent_at, DateFormat.getTimeFormat(this).format(last)) else getString(R.string.nothing_sent),
prefs.queued.takeIf { it > 0 }?.let { getString(R.string.waiting, it.toInt()) },
prefs.error,
if (missingPermissions()) "Some permissions are missing" else null,
if (missingPermissions()) getString(R.string.permissions_missing) else null,
).joinToString(" · ")
}
}
private fun showMenu(anchor: View) {
val menu = PopupMenu(this, anchor)
menu.menu.add("Permissions").setOnMenuItemClickListener {
menu.menu.add(R.string.menu_permissions).setOnMenuItemClickListener {
showPermissions()
true
}
menu.menu.add("Web UI").setOnMenuItemClickListener {
if (!openInBrowser("${prefs.url}/")) showError("No browser found.")
menu.menu.add(R.string.menu_web).setOnMenuItemClickListener {
if (!openInBrowser("${prefs.url}/")) showError(getString(R.string.no_browser))
true
}
menu.menu.add("Disconnect").setOnMenuItemClickListener {
menu.menu.add(R.string.menu_disconnect).setOnMenuItemClickListener {
AlertDialog.Builder(this)
.setTitle("Disconnect this phone?")
.setMessage("Tracking stops. The phone stays in Settings → Devices until you remove it there.")
.setPositiveButton("Disconnect") { _, _ ->
.setTitle(R.string.disconnect_title)
.setMessage(R.string.disconnect_text)
.setPositiveButton(R.string.menu_disconnect) { _, _ ->
stopTracking()
prefs.token = null
}
.setNegativeButton("Cancel", null)
.setNegativeButton(R.string.cancel, null)
.show()
true
}
@@ -240,7 +240,7 @@ class MainActivity : Activity() {
// Pairing: the browser creates a one-time code for the hash of a secret that only this app knows.
private fun pair(input: String) {
val url = Api.normalize(input) ?: return showError("Enter the server address.")
val url = Api.normalize(input) ?: return showError(getString(R.string.enter_address))
val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) }
val verifier = bytes.toHex()
prefs.url = url
@@ -249,7 +249,7 @@ class MainActivity : Activity() {
val challenge = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()).toHex()
val name = Uri.encode(Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL)
if (!openInBrowser("$url/#pair?challenge=$challenge&name=$name")) {
showError("No browser found. Paste a device token instead.")
showError(getString(R.string.no_browser_paste))
}
}
@@ -260,31 +260,31 @@ class MainActivity : Activity() {
val code = data.getQueryParameter("code") ?: return
val url = prefs.url
val verifier = prefs.pairVerifier
if (url == null || verifier == null) return showError("Start connecting in the app first.")
if (url == null || verifier == null) return showError(getString(R.string.start_in_app))
thread {
val result = runCatching { Api(url).pair(code, verifier) }
runOnUiThread {
result
.onSuccess { connected(it) }
.onFailure { showError("Connecting failed. Start again. (${it.message})") }
.onFailure { showError(getString(R.string.connect_failed, it.message)) }
}
}
}
/** Accepts the `ot use-token <url> <token>` line from the web UI, or only the token. */
private fun pasteToken(typedUrl: String) {
val input = EditText(this).apply { hint = "ot use-token https://… token" }
val input = EditText(this).apply { setHint(R.string.token_hint) }
AlertDialog.Builder(this)
.setTitle("Device token")
.setMessage("Create a token in the web UI under Settings → Devices.")
.setTitle(R.string.token_title)
.setMessage(R.string.token_text)
.setView(input)
.setPositiveButton("Connect") { _, _ ->
.setPositiveButton(R.string.connect) { _, _ ->
val words = input.text.toString().trim().split(Regex("\\s+"))
val token = words.lastOrNull { it.matches(Regex("[0-9a-f]{64}")) }
?: return@setPositiveButton showError("That is not a device token.")
?: return@setPositiveButton showError(getString(R.string.not_a_token))
val url = words.firstNotNullOfOrNull { w -> w.takeIf { it.contains("://") }?.let(Api::normalize) }
?: Api.normalize(typedUrl)
?: return@setPositiveButton showError("Enter the server address.")
?: return@setPositiveButton showError(getString(R.string.enter_address))
thread {
val result = runCatching { Api(url, token).device() }
runOnUiThread {
@@ -293,11 +293,11 @@ class MainActivity : Activity() {
prefs.url = url
connected(token)
}
.onFailure { showError("The server did not accept the token. (${it.message})") }
.onFailure { showError(getString(R.string.token_refused, it.message)) }
}
}
}
.setNegativeButton("Cancel", null)
.setNegativeButton(R.string.cancel, null)
.show()
}
@@ -339,16 +339,16 @@ class MainActivity : Activity() {
}
if (!granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) && once("background")) {
explain(
"Allow location all the time",
"Choose \"Allow all the time\" on the next screen. Then tracking can restart by itself after the phone reboots.",
R.string.background_title,
R.string.background_text,
) { requestPermissions(arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), REQUEST_OTHER) }
return
}
val power = getSystemService(PowerManager::class.java)
if (!power.isIgnoringBatteryOptimizations(packageName) && once("battery")) {
explain(
"Keep tracking running",
"Some phones stop background apps to save battery. Allow opentracker to run in the background, so tracking does not stop.",
R.string.battery_title,
R.string.battery_text,
) {
@SuppressLint("BatteryLife")
val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"))
@@ -380,12 +380,12 @@ class MainActivity : Activity() {
return true
}
private fun explain(title: String, message: String, next: () -> Unit) {
private fun explain(title: Int, message: Int, next: () -> Unit) {
AlertDialog.Builder(this)
.setTitle(title)
.setMessage(message)
.setPositiveButton("Continue") { _, _ -> next() }
.setNegativeButton("Skip") { _, _ -> startTracking() }
.setPositiveButton(R.string.continue_) { _, _ -> next() }
.setNegativeButton(R.string.skip) { _, _ -> startTracking() }
.setCancelable(false)
.show()
}
@@ -405,13 +405,13 @@ class MainActivity : Activity() {
val fine = granted(Manifest.permission.ACCESS_FINE_LOCATION)
val battery = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
return buildList {
add(Permission("Precise location", "Without it there is no tracking.", fine) {
add(Permission(getString(R.string.perm_precise), getString(R.string.perm_precise_loss), fine) {
ask(Manifest.permission.ACCESS_FINE_LOCATION, "location", Manifest.permission.ACCESS_COARSE_LOCATION)
})
add(
Permission(
"Location all the time",
"Without it tracking does not restart after a reboot.",
getString(R.string.perm_always),
getString(R.string.perm_always_loss),
granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
// Android grants this only on top of precise location.
fix = if (fine) ({ ask(Manifest.permission.ACCESS_BACKGROUND_LOCATION, "background") }) else null,
@@ -420,14 +420,14 @@ class MainActivity : Activity() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(
Permission(
"Notifications",
"Without it the tracking notification is hidden.",
getString(R.string.perm_notifications),
getString(R.string.perm_notifications_loss),
granted(Manifest.permission.POST_NOTIFICATIONS),
needed = false,
) { ask(Manifest.permission.POST_NOTIFICATIONS, "notifications") },
)
}
add(Permission("Run in the background", "Without it some phones stop tracking to save battery.", battery) {
add(Permission(getString(R.string.perm_background), getString(R.string.perm_background_loss), battery) {
@SuppressLint("BatteryLife")
val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"))
runCatching { startActivity(request) }
@@ -460,9 +460,9 @@ class MainActivity : Activity() {
fillPermissions(rows)
permissionRows = rows
AlertDialog.Builder(this)
.setTitle("Permissions")
.setTitle(R.string.menu_permissions)
.setView(ScrollView(this).apply { addView(rows) })
.setPositiveButton("Close", null)
.setPositiveButton(R.string.close, null)
.setOnDismissListener { permissionRows = null }
.show()
}
@@ -476,13 +476,13 @@ class MainActivity : Activity() {
setPadding(0, dp(12), 0, 0)
})
rows.addView(TextView(this).apply {
text = if (p.granted) "Allowed" else "Not allowed. ${p.loss}"
text = if (p.granted) getString(R.string.allowed) else getString(R.string.not_allowed, p.loss)
if (!p.granted) setTextColor(0xffdc2626.toInt())
})
val fix = p.fix
if (!p.granted && fix != null) {
rows.addView(Button(this).apply {
text = "Allow"
setText(R.string.allow)
setOnClickListener { fix() }
})
}
@@ -496,7 +496,7 @@ class MainActivity : Activity() {
return
}
if (requestCode == REQUEST_LOCATION && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) {
prefs.error = "Tracking needs the location permission."
prefs.error = getString(R.string.needs_location)
starting = false
showStatus()
return
▾Mandroid/app/src/main/java/org/opentracker/Prefs.kt
@@ -24,6 +24,7 @@ class Prefs(context: Context) {
get() = raw.getString("pairVerifier", null)
set(v) = raw.edit().putString("pairVerifier", v).apply()
/** A [Mode] name. */
var mode: String?
get() = raw.getString("mode", null)
set(v) = raw.edit().putString("mode", v).apply()
@@ -40,6 +41,15 @@ class Prefs(context: Context) {
get() = raw.getString("error", null)
set(v) = raw.edit().putString("error", v).apply()
/** The mode in the user's language. */
fun modeLabel(context: Context): String? = when (mode) {
Mode.VEHICLE.name -> context.getString(R.string.mode_vehicle)
Mode.WALK.name -> context.getString(R.string.mode_walk)
Mode.DWELL.name -> context.getString(R.string.mode_dwell)
Mode.STATIONARY.name -> context.getString(R.string.mode_stationary)
else -> null
}
fun asked(step: String): Boolean = raw.getBoolean("asked $step", false)
fun markAsked(step: String) = raw.edit().putBoolean("asked $step", true).apply()
▾Mandroid/app/src/main/java/org/opentracker/SamplingPolicy.kt
@@ -14,11 +14,11 @@ data class Fix(
val speed: Float? = null,
)
enum class Mode(val label: String) {
VEHICLE("Driving"),
WALK("Moving"),
DWELL("Stopped"),
STATIONARY("Still"),
enum class Mode {
VEHICLE,
WALK,
DWELL,
STATIONARY,
}
/** What to ask LocationManager for. */
▾Mandroid/app/src/main/java/org/opentracker/TrackerService.kt
@@ -70,7 +70,7 @@ class TrackerService : Service() {
source = LocationSource(getSystemService(LocationManager::class.java), handler::post, ::onFixes)
motion = MotionDetector(this) { handler.post(::onMotion) }
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(CHANNEL, "Tracking", NotificationManager.IMPORTANCE_LOW),
NotificationChannel(CHANNEL, getString(R.string.tracking), NotificationManager.IMPORTANCE_LOW),
)
}
@@ -86,7 +86,7 @@ class TrackerService : Service() {
} catch (e: RuntimeException) {
// Android refuses a location service started in the background without "Allow all the time",
// for example after a reboot. Then the user has to open the app.
prefs.error = "Tracking stopped. Open the app to start it again."
prefs.error = getString(R.string.tracking_stopped)
stopSelf()
return START_NOT_STICKY
}
@@ -167,8 +167,8 @@ class TrackerService : Service() {
}
}
if (policy.mode == Mode.STATIONARY) motion.arm() else motion.disarm()
if (prefs.mode != policy.mode.label) {
prefs.mode = policy.mode.label
if (prefs.mode != policy.mode.name) {
prefs.mode = policy.mode.name
updateNotification()
}
// A heartbeat point goes alone: one point is one radio wakeup either way.
@@ -221,13 +221,13 @@ class TrackerService : Service() {
// The server will refuse this batch again. Keeping it would block every later point.
e.status in 400..499 && e.status != 429 -> {
outbox.removeUpTo(batch.last().first)
prefs.error = "The server refused ${batch.size} points: ${e.message}"
prefs.error = resources.getQuantityString(R.plurals.server_refused_points, batch.size, batch.size, e.message)
schedule(urgent = true)
}
else -> retry("The server refused the upload: ${e.message}")
else -> retry(getString(R.string.server_refused_upload, e.message))
}
} catch (e: IOException) {
retry("No connection to the server.")
retry(getString(R.string.no_connection))
}
}
@@ -243,7 +243,7 @@ class TrackerService : Service() {
private fun removed() {
prefs.token = null
prefs.tracking = false
prefs.error = "This phone was removed from your account. Connect it again."
prefs.error = getString(R.string.phone_removed)
stopSelf()
}
@@ -257,12 +257,12 @@ class TrackerService : Service() {
)
val last = prefs.lastUploadMs
val text = listOfNotNull(
prefs.mode,
if (last > 0) "last upload ${DateFormat.getTimeFormat(this).format(last)}" else null,
prefs.modeLabel(this),
if (last > 0) getString(R.string.last_upload, DateFormat.getTimeFormat(this).format(last)) else null,
).joinToString(" · ")
return Notification.Builder(this, CHANNEL)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Sharing your location")
.setContentTitle(getString(R.string.notification_title))
.setContentText(text)
.setContentIntent(open)
.setOngoing(true)
▾Aandroid/app/src/main/res/values-de/strings.xml
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="setup_title">Mit deinem Server verbinden</string>
<string name="setup_text">Gib die Adresse deines opentracker-Servers ein. Danach meldest du dich im Browser an und bestätigst dieses Handy.</string>
<string name="connect_in_browser">Im Browser verbinden</string>
<string name="paste_token">Geräte-Token einfügen</string>
<string name="tracking">Ortung</string>
<string name="more">Mehr</string>
<string name="tracking_off">Ortung ist aus</string>
<string name="sent_at">gesendet %1$s</string>
<string name="nothing_sent">noch nichts gesendet</string>
<string name="waiting">ausstehend: %1$d</string>
<string name="permissions_missing">Einige Berechtigungen fehlen</string>
<string name="menu_permissions">Berechtigungen</string>
<string name="menu_web">Weboberfläche</string>
<string name="menu_disconnect">Trennen</string>
<string name="disconnect_title">Dieses Handy trennen?</string>
<string name="disconnect_text">Die Ortung stoppt. Das Handy bleibt unter Einstellungen → Geräte, bis du es dort entfernst.</string>
<string name="cancel">Abbrechen</string>
<string name="no_browser">Kein Browser gefunden.</string>
<string name="enter_address">Gib die Serveradresse ein.</string>
<string name="no_browser_paste">Kein Browser gefunden. Füge stattdessen ein Geräte-Token ein.</string>
<string name="start_in_app">Starte das Verbinden zuerst in der App.</string>
<string name="connect_failed">Verbinden fehlgeschlagen. Beginne erneut. (%1$s)</string>
<string name="token_title">Geräte-Token</string>
<string name="token_text">Erstelle ein Token in der Weboberfläche unter Einstellungen → Geräte.</string>
<string name="connect">Verbinden</string>
<string name="not_a_token">Das ist kein Geräte-Token.</string>
<string name="token_refused">Der Server hat das Token nicht angenommen. (%1$s)</string>
<string name="background_title">Standort immer erlauben</string>
<string name="background_text">Wähle auf dem nächsten Bildschirm „Immer zulassen“. Dann startet die Ortung nach einem Neustart des Handys von selbst.</string>
<string name="battery_title">Ortung weiterlaufen lassen</string>
<string name="battery_text">Manche Handys beenden Hintergrund-Apps, um Akku zu sparen. Erlaube opentracker, im Hintergrund zu laufen, damit die Ortung nicht stoppt.</string>
<string name="continue_">Weiter</string>
<string name="skip">Überspringen</string>
<string name="perm_precise">Genauer Standort</string>
<string name="perm_precise_loss">Ohne ihn gibt es keine Ortung.</string>
<string name="perm_always">Standort immer</string>
<string name="perm_always_loss">Ohne ihn startet die Ortung nach einem Neustart nicht von selbst.</string>
<string name="perm_notifications">Benachrichtigungen</string>
<string name="perm_notifications_loss">Ohne sie ist die Ortungs-Benachrichtigung ausgeblendet.</string>
<string name="perm_background">Im Hintergrund laufen</string>
<string name="perm_background_loss">Ohne sie beenden manche Handys die Ortung, um Akku zu sparen.</string>
<string name="close">Schließen</string>
<string name="allowed">Erlaubt</string>
<string name="not_allowed">Nicht erlaubt. %1$s</string>
<string name="allow">Erlauben</string>
<string name="needs_location">Die Ortung braucht die Standortberechtigung.</string>
<string name="tracking_stopped">Die Ortung wurde gestoppt. Öffne die App, um sie wieder zu starten.</string>
<plurals name="server_refused_points">
<item quantity="one">Der Server hat %1$d Punkt abgelehnt: %2$s</item>
<item quantity="other">Der Server hat %1$d Punkte abgelehnt: %2$s</item>
</plurals>
<string name="server_refused_upload">Der Server hat den Upload abgelehnt: %1$s</string>
<string name="no_connection">Keine Verbindung zum Server.</string>
<string name="phone_removed">Dieses Handy wurde aus deinem Konto entfernt. Verbinde es erneut.</string>
<string name="last_upload">letzter Upload %1$s</string>
<string name="notification_title">Dein Standort wird geteilt</string>
<string name="mode_vehicle">Fährt</string>
<string name="mode_walk">Unterwegs</string>
<string name="mode_dwell">Angehalten</string>
<string name="mode_stationary">Steht</string>
</resources>
▾Aandroid/app/src/main/res/values/strings.xml
@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">opentracker</string>
<string name="setup_title">Connect to your server</string>
<string name="setup_text">Enter the address of your opentracker server. Then sign in with the browser and confirm this phone.</string>
<string name="connect_in_browser">Connect in browser</string>
<string name="paste_token">Paste a device token</string>
<string name="tracking">Tracking</string>
<string name="more">More</string>
<string name="tracking_off">Tracking is off</string>
<string name="sent_at">sent %1$s</string>
<string name="nothing_sent">nothing sent yet</string>
<string name="waiting">waiting: %1$d</string>
<string name="permissions_missing">Some permissions are missing</string>
<string name="menu_permissions">Permissions</string>
<string name="menu_web">Web UI</string>
<string name="menu_disconnect">Disconnect</string>
<string name="disconnect_title">Disconnect this phone?</string>
<string name="disconnect_text">Tracking stops. The phone stays in Settings → Devices until you remove it there.</string>
<string name="cancel">Cancel</string>
<string name="no_browser">No browser found.</string>
<string name="enter_address">Enter the server address.</string>
<string name="no_browser_paste">No browser found. Paste a device token instead.</string>
<string name="start_in_app">Start connecting in the app first.</string>
<string name="connect_failed">Connecting failed. Start again. (%1$s)</string>
<string name="token_title">Device token</string>
<string name="token_text">Create a token in the web UI under Settings → Devices.</string>
<string name="connect">Connect</string>
<string name="not_a_token">That is not a device token.</string>
<string name="token_refused">The server did not accept the token. (%1$s)</string>
<string name="background_title">Allow location all the time</string>
<string name="background_text">Choose “Allow all the time” on the next screen. Then tracking can restart by itself after the phone reboots.</string>
<string name="battery_title">Keep tracking running</string>
<string name="battery_text">Some phones stop background apps to save battery. Allow opentracker to run in the background, so tracking does not stop.</string>
<string name="continue_">Continue</string>
<string name="skip">Skip</string>
<string name="perm_precise">Precise location</string>
<string name="perm_precise_loss">Without it there is no tracking.</string>
<string name="perm_always">Location all the time</string>
<string name="perm_always_loss">Without it tracking does not restart after a reboot.</string>
<string name="perm_notifications">Notifications</string>
<string name="perm_notifications_loss">Without it the tracking notification is hidden.</string>
<string name="perm_background">Run in the background</string>
<string name="perm_background_loss">Without it some phones stop tracking to save battery.</string>
<string name="close">Close</string>
<string name="allowed">Allowed</string>
<string name="not_allowed">Not allowed. %1$s</string>
<string name="allow">Allow</string>
<string name="needs_location">Tracking needs the location permission.</string>
<string name="tracking_stopped">Tracking stopped. Open the app to start it again.</string>
<plurals name="server_refused_points">
<item quantity="one">The server refused %1$d point: %2$s</item>
<item quantity="other">The server refused %1$d points: %2$s</item>
</plurals>
<string name="server_refused_upload">The server refused the upload: %1$s</string>
<string name="no_connection">No connection to the server.</string>
<string name="phone_removed">This phone was removed from your account. Connect it again.</string>
<string name="last_upload">last upload %1$s</string>
<string name="notification_title">Sharing your location</string>
<string name="mode_vehicle">Driving</string>
<string name="mode_walk">Moving</string>
<string name="mode_dwell">Stopped</string>
<string name="mode_stationary">Still</string>
<string name="url_hint" translatable="false">https://track.example.com</string>
<string name="token_hint" translatable="false">ot use-token https://… token</string>
</resources>
▾Aandroid/app/src/main/res/xml/locales_config.xml
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="en" />
<locale android:name="de" />
</locale-config>