Initial commit: Helios Tracker location responder app

Minimal Android app that receives LOCATE commands via ntfy push
notifications and replies with GPS coordinates + battery level.
Uses WorkManager, FusedLocationProvider, and OkHttp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Moritz 2026-02-16 16:49:56 +01:00
commit 2f54b5a085
45 changed files with 1386 additions and 0 deletions

1
app/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
/build

70
app/build.gradle.kts Normal file
View file

@ -0,0 +1,70 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.helios_location_finder"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.example.helios_location_finder"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.activity.compose)
debugImplementation(libs.compose.ui.tooling)
// WorkManager
implementation(libs.work.runtime.ktx)
// Google Play Services Location
implementation(libs.play.services.location)
// OkHttp
implementation(libs.okhttp)
// Coroutines for Play Services .await()
implementation(libs.coroutines.play.services)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
}

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

21
app/proguard-rules.pro vendored Normal file
View file

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View file

@ -0,0 +1,24 @@
package com.example.helios_location_finder
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.helios_location_finder", appContext.packageName)
}
}

View file

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Helioslocationfinder"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.Helioslocationfinder">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".NtfyReceiver"
android:exported="true">
<intent-filter>
<action android:name="io.heckel.ntfy.MESSAGE_RECEIVED" />
</intent-filter>
</receiver>
</application>
</manifest>

View file

@ -0,0 +1,175 @@
package com.example.helios_location_finder
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.location.Location
import android.os.BatteryManager
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import com.google.android.gms.location.LocationCallback
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 com.google.android.gms.tasks.CancellationTokenSource
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import kotlin.coroutines.resume
class LocationWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
companion object {
private const val TAG = "LocationWorker"
private const val NTFY_BASE_URL = "https://ntfy.sh/"
private const val LOCATION_TIMEOUT_MS = 30_000L
}
private val client = OkHttpClient()
override suspend fun doWork(): Result {
Log.d(TAG, "LocationWorker started")
val replyTopic = Prefs.getReplyTopic(applicationContext)
if (replyTopic.isBlank()) {
Log.e(TAG, "No reply topic configured")
return Result.failure()
}
val replyUrl = NTFY_BASE_URL + replyTopic
if (!hasLocationPermission()) {
Log.e(TAG, "No location permission")
sendMessage(replyUrl, "Error: Location permission not granted")
return Result.failure()
}
val locationClient = LocationServices.getFusedLocationProviderClient(applicationContext)
try {
// Strategy 1: getCurrentLocation
val cancellationSource = CancellationTokenSource()
var location: Location? = try {
withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
locationClient.getCurrentLocation(
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
cancellationSource.token
).await()
}
} catch (e: Exception) {
Log.w(TAG, "getCurrentLocation failed: ${e.message}")
null
} finally {
cancellationSource.cancel()
}
// Strategy 2: getLastLocation as fallback
if (location == null) {
Log.d(TAG, "getCurrentLocation returned null, trying getLastLocation")
location = try {
locationClient.lastLocation.await()
} catch (e: Exception) {
Log.w(TAG, "getLastLocation failed: ${e.message}")
null
}
}
// Strategy 3: requestLocationUpdates as last resort
if (location == null) {
Log.d(TAG, "getLastLocation returned null, trying requestLocationUpdates")
location = withTimeoutOrNull(LOCATION_TIMEOUT_MS) {
requestSingleUpdate(locationClient)
}
}
if (location == null) {
Log.w(TAG, "All location strategies failed")
sendMessage(replyUrl, "Error: Could not determine location")
return Result.retry()
}
val battery = getBatteryLevel()
val payload = "Lat: ${location.latitude}, Lon: ${location.longitude}, Battery: $battery%"
Log.d(TAG, "Sending location: $payload")
sendMessage(replyUrl, payload)
return Result.success()
} catch (e: SecurityException) {
Log.e(TAG, "SecurityException", e)
sendMessage(replyUrl, "Error: SecurityException - ${e.message}")
return Result.failure()
} catch (e: Exception) {
Log.e(TAG, "Unexpected error", e)
sendMessage(replyUrl, "Error: ${e.message}")
return Result.retry()
}
}
private suspend fun requestSingleUpdate(
locationClient: com.google.android.gms.location.FusedLocationProviderClient
): Location = suspendCancellableCoroutine { cont ->
val request = LocationRequest.Builder(
Priority.PRIORITY_BALANCED_POWER_ACCURACY, 1000L
).setMaxUpdates(1).build()
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult) {
locationClient.removeLocationUpdates(this)
val loc = result.lastLocation
if (loc != null && cont.isActive) {
cont.resume(loc)
}
}
}
locationClient.requestLocationUpdates(request, callback, Looper.getMainLooper())
cont.invokeOnCancellation {
locationClient.removeLocationUpdates(callback)
}
}
private fun hasLocationPermission(): Boolean {
return ContextCompat.checkSelfPermission(
applicationContext, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
applicationContext, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
private fun getBatteryLevel(): Int {
val intentFilter = IntentFilter(Intent.ACTION_BATTERY_CHANGED)
val batteryStatus = applicationContext.registerReceiver(null, intentFilter)
val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
return if (level >= 0 && scale > 0) (level * 100 / scale) else -1
}
private fun sendMessage(url: String, text: String) {
try {
val request = Request.Builder()
.url(url)
.post(text.toRequestBody("text/plain".toMediaType()))
.build()
client.newCall(request).execute().use { response ->
Log.d(TAG, "POST response: ${response.code}")
}
} catch (e: Exception) {
Log.e(TAG, "Failed to send message", e)
}
}
}

View file

@ -0,0 +1,242 @@
package com.example.helios_location_finder
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
private val Orange = Color(0xFFFF6D00)
private val OrangeLight = Color(0xFFFFAB40)
private val DarkSurface = Color(0xFF1A1A1A)
private val HeliosDarkColorScheme = darkColorScheme(
primary = Orange,
onPrimary = Color.Black,
secondary = OrangeLight,
onSecondary = Color.Black,
background = Color.Black,
surface = DarkSurface,
onBackground = Color.White,
onSurface = Color.White,
error = Color(0xFFCF6679),
)
class MainActivity : ComponentActivity() {
private val foregroundGranted = mutableStateOf(false)
private val backgroundGranted = mutableStateOf(false)
private val foregroundPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { permissions ->
foregroundGranted.value = permissions.values.any { it }
checkPermissions()
}
private val backgroundPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
backgroundGranted.value = granted
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkPermissions()
val listenTopic = mutableStateOf(Prefs.getListenTopic(this))
val replyTopic = mutableStateOf(Prefs.getReplyTopic(this))
setContent {
MaterialTheme(colorScheme = HeliosDarkColorScheme) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
StatusScreen(
foregroundGranted = foregroundGranted.value,
backgroundGranted = backgroundGranted.value,
listenTopic = listenTopic.value,
replyTopic = replyTopic.value,
onListenTopicChange = { value ->
listenTopic.value = value
Prefs.setListenTopic(this, value)
},
onReplyTopicChange = { value ->
replyTopic.value = value
Prefs.setReplyTopic(this, value)
},
onRequestForegroundPermission = { requestForegroundPermissions() },
onRequestBackgroundPermission = { requestBackgroundPermission() }
)
}
}
}
}
override fun onResume() {
super.onResume()
checkPermissions()
}
private fun checkPermissions() {
foregroundGranted.value = ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED
backgroundGranted.value = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContextCompat.checkSelfPermission(
this, Manifest.permission.ACCESS_BACKGROUND_LOCATION
) == PackageManager.PERMISSION_GRANTED
} else {
true
}
}
private fun requestForegroundPermissions() {
foregroundPermissionLauncher.launch(
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
)
}
private fun requestBackgroundPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
backgroundPermissionLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
}
}
}
@Composable
fun StatusScreen(
foregroundGranted: Boolean,
backgroundGranted: Boolean,
listenTopic: String,
replyTopic: String,
onListenTopicChange: (String) -> Unit,
onReplyTopicChange: (String) -> Unit,
onRequestForegroundPermission: () -> Unit,
onRequestBackgroundPermission: () -> Unit
) {
val textFieldColors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Orange,
unfocusedBorderColor = OrangeLight.copy(alpha = 0.5f),
focusedLabelColor = Orange,
cursorColor = Orange,
)
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = "Helios Tracker",
style = MaterialTheme.typography.headlineMedium,
color = Orange
)
Spacer(modifier = Modifier.height(32.dp))
val statusText = when {
!foregroundGranted -> "Standort-Berechtigung fehlt"
!backgroundGranted -> "Hintergrund-Standort fehlt"
else -> "Bereit"
}
val isReady = foregroundGranted && backgroundGranted
Text(
text = "Status: $statusText",
style = MaterialTheme.typography.bodyLarge,
color = if (isReady) Orange else MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(24.dp))
OutlinedTextField(
value = listenTopic,
onValueChange = onListenTopicChange,
label = { Text("Empfangs-Topic (lauschen)") },
placeholder = { Text("z.B. mein_geraet_locate") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = textFieldColors
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = replyTopic,
onValueChange = onReplyTopicChange,
label = { Text("Antwort-Topic (senden)") },
placeholder = { Text("z.B. mein_geraet_reply") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
colors = textFieldColors
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Die App lauscht auf ntfy-Nachrichten mit dem Inhalt \"LOCATE\" " +
"auf dem Empfangs-Topic und antwortet mit dem Standort auf dem Antwort-Topic.",
style = MaterialTheme.typography.bodyMedium,
color = Color.LightGray
)
if (!foregroundGranted) {
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onRequestForegroundPermission,
colors = ButtonDefaults.buttonColors(containerColor = Orange)
) {
Text("Standort-Berechtigung erteilen", color = Color.Black)
}
} else if (!backgroundGranted) {
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onRequestBackgroundPermission,
colors = ButtonDefaults.buttonColors(containerColor = Orange)
) {
Text("Hintergrund-Standort erlauben", color = Color.Black)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Damit die App auf LOCATE-Anfragen reagieren kann, " +
"muss \"Immer erlauben\" gewaehlt werden.",
style = MaterialTheme.typography.bodySmall,
color = Color.Gray
)
}
}
}

View file

@ -0,0 +1,42 @@
package com.example.helios_location_finder
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
class NtfyReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "NtfyReceiver"
}
override fun onReceive(context: Context, intent: Intent) {
val message = intent.getStringExtra("message") ?: return
val topic = intent.getStringExtra("topic") ?: ""
Log.d(TAG, "Received ntfy message on topic '$topic': $message")
val listenTopic = Prefs.getListenTopic(context)
if (listenTopic.isBlank()) {
Log.w(TAG, "No listen topic configured, ignoring")
return
}
if (topic != listenTopic) {
Log.d(TAG, "Topic '$topic' does not match configured '$listenTopic', ignoring")
return
}
if (message.trim().equals("LOCATE", ignoreCase = true)) {
Log.d(TAG, "LOCATE command received, enqueuing LocationWorker")
val workRequest = OneTimeWorkRequestBuilder<LocationWorker>().build()
WorkManager.getInstance(context).enqueueUniqueWork(
"locate", ExistingWorkPolicy.REPLACE, workRequest
)
}
}
}

View file

@ -0,0 +1,24 @@
package com.example.helios_location_finder
import android.content.Context
object Prefs {
private const val NAME = "helios_prefs"
const val KEY_LISTEN_TOPIC = "listen_topic"
const val KEY_REPLY_TOPIC = "reply_topic"
private fun prefs(context: Context) =
context.getSharedPreferences(NAME, Context.MODE_PRIVATE)
fun getListenTopic(context: Context): String =
prefs(context).getString(KEY_LISTEN_TOPIC, "") ?: ""
fun getReplyTopic(context: Context): String =
prefs(context).getString(KEY_REPLY_TOPIC, "") ?: ""
fun setListenTopic(context: Context, topic: String) =
prefs(context).edit().putString(KEY_LISTEN_TOPIC, topic).apply()
fun setReplyTopic(context: Context, topic: String) =
prefs(context).edit().putString(KEY_REPLY_TOPIC, topic).apply()
}

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/black" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/black" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View file

@ -0,0 +1,9 @@
<resources>
<style name="Theme.Helioslocationfinder" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">@color/orange</item>
<item name="android:colorAccent">@color/orange_light</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:statusBarColor">@color/black</item>
<item name="android:navigationBarColor">@color/black</item>
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="orange">#FFFF6D00</color>
<color name="orange_light">#FFFFAB40</color>
<color name="orange_dark">#FFCC5500</color>
<color name="black">#FF000000</color>
<color name="dark_surface">#FF1A1A1A</color>
<color name="white">#FFFFFFFF</color>
<color name="gray_light">#FFB0B0B0</color>
</resources>

View file

@ -0,0 +1,3 @@
<resources>
<string name="app_name">Helios Tracker</string>
</resources>

View file

@ -0,0 +1,9 @@
<resources>
<style name="Theme.Helioslocationfinder" parent="android:Theme.Material.NoActionBar">
<item name="android:colorPrimary">@color/orange</item>
<item name="android:colorAccent">@color/orange_light</item>
<item name="android:windowBackground">@color/black</item>
<item name="android:statusBarColor">@color/black</item>
<item name="android:navigationBarColor">@color/black</item>
</style>
</resources>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>

View file

@ -0,0 +1,17 @@
package com.example.helios_location_finder
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}