Feature/location settings (#129)

* Initial work on zone based location tracking.

* Tests and enhancements.

* Fix unit tests.

* Fix test cases.

* Initial location preferences work.

* Location settings now function will requests for permissions when needed.

* ktlink formatting.

* Domain tests.

* Data tests

* Extract strings into resources.

* Remove translations until Lokalise SDK is added back.
Failing lint

* Update wording.

* Add icons and toolbar.

* Coloring the settings.
This commit is contained in:
Justin Bassett 2019-12-11 15:02:31 -05:00 committed by Robbie Trencheny
parent 0e515a8222
commit 594936c088
38 changed files with 704 additions and 219 deletions

View file

@ -83,8 +83,9 @@ dependencies {
kapt "com.google.dagger:dagger-compiler:${daggerVersion}"
implementation "androidx.appcompat:appcompat:$appCompatVersion"
implementation 'com.google.android.material:material:1.0.0'
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutversion"
implementation "androidx.preference:preference-ktx:1.1.0"
implementation 'com.google.android.material:material:1.0.0'
implementation("com.jakewharton.threetenabp:threetenabp:$threeTenAbpVersion") {
exclude group: 'org.threeten'

View file

@ -27,7 +27,9 @@
android:exported="true"
android:permission="android.permission.RECEIVE_BOOT_COMPLETED">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON"/>
<action android:name="com.htc.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
@ -40,7 +42,9 @@
</activity>
<activity android:name=".onboarding.OnboardingActivity" />
<activity android:name=".webview.WebViewActivity" />
<activity android:name=".settings.SettingsActivity" />
<activity
android:name=".settings.SettingsActivity"
android:parentActivityName=".webview.WebViewActivity"/>
</application>
</manifest>

View file

@ -6,6 +6,8 @@ import io.homeassistant.companion.android.launch.LaunchActivity
import io.homeassistant.companion.android.onboarding.authentication.AuthenticationFragment
import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegrationFragment
import io.homeassistant.companion.android.onboarding.manual.ManualSetupFragment
import io.homeassistant.companion.android.settings.SettingsActivity
import io.homeassistant.companion.android.settings.SettingsFragment
import io.homeassistant.companion.android.webview.WebViewActivity
@Component(dependencies = [AppComponent::class], modules = [PresenterModule::class])
@ -19,5 +21,9 @@ interface PresenterComponent {
fun inject(fragment: MobileAppIntegrationFragment)
fun inject(activity: SettingsActivity)
fun inject(fragment: SettingsFragment)
fun inject(activity: WebViewActivity)
}

View file

@ -15,6 +15,9 @@ import io.homeassistant.companion.android.onboarding.integration.MobileAppIntegr
import io.homeassistant.companion.android.onboarding.manual.ManualSetupPresenter
import io.homeassistant.companion.android.onboarding.manual.ManualSetupPresenterImpl
import io.homeassistant.companion.android.onboarding.manual.ManualSetupView
import io.homeassistant.companion.android.settings.SettingsPresenter
import io.homeassistant.companion.android.settings.SettingsPresenterImpl
import io.homeassistant.companion.android.settings.SettingsView
import io.homeassistant.companion.android.webview.WebView
import io.homeassistant.companion.android.webview.WebViewPresenter
import io.homeassistant.companion.android.webview.WebViewPresenterImpl
@ -26,6 +29,7 @@ class PresenterModule {
private lateinit var authenticationView: AuthenticationView
private lateinit var manualSetupView: ManualSetupView
private lateinit var mobileAppIntegrationView: MobileAppIntegrationView
private lateinit var settingsView: SettingsView
private lateinit var webView: WebView
constructor(launchView: LaunchView) {
@ -44,6 +48,10 @@ class PresenterModule {
this.mobileAppIntegrationView = mobileAppIntegrationView
}
constructor(settingsView: SettingsView) {
this.settingsView = settingsView
}
constructor(webView: WebView) {
this.webView = webView
}
@ -60,6 +68,9 @@ class PresenterModule {
@Provides
fun provideMobileAppIntegrationView() = mobileAppIntegrationView
@Provides
fun provideSettingsView() = settingsView
@Provides
fun provideWebView() = webView
@ -78,6 +89,9 @@ class PresenterModule {
@Binds
fun bindMobileAppPresenter(presenter: MobileAppIntegrationPresenterImpl): MobileAppIntegrationPresenter
@Binds
fun bindSettingsPresenter(presenter: SettingsPresenterImpl): SettingsPresenter
@Binds
fun bindWebViewPresenterImpl(presenter: WebViewPresenterImpl): WebViewPresenter
}

View file

@ -7,10 +7,14 @@ 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.Build
import android.util.Log
import androidx.core.app.ActivityCompat
import com.google.android.gms.location.Geofence
import com.google.android.gms.location.GeofencingEvent
import com.google.android.gms.location.GeofencingRequest
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
@ -30,6 +34,8 @@ class LocationBroadcastReceiver : BroadcastReceiver() {
"io.homeassistant.companion.android.background.REQUEST_UPDATES"
const val ACTION_PROCESS_LOCATION =
"io.homeassistant.companion.android.background.PROCESS_UPDATES"
const val ACTION_PROCESS_GEO =
"io.homeassistant.companion.android.background.PROCESS_GEOFENCE"
private const val TAG = "LocBroadcastReceiver"
}
@ -43,9 +49,10 @@ class LocationBroadcastReceiver : BroadcastReceiver() {
ensureInjected(context)
when (intent.action) {
Intent.ACTION_BOOT_COMPLETED -> requestUpdates(context)
ACTION_REQUEST_LOCATION_UPDATES -> requestUpdates(context)
ACTION_PROCESS_LOCATION -> handleUpdate(context, intent)
Intent.ACTION_BOOT_COMPLETED -> setupLocationTracking(context)
ACTION_REQUEST_LOCATION_UPDATES -> setupLocationTracking(context)
ACTION_PROCESS_LOCATION -> handleLocationUpdate(context, intent)
ACTION_PROCESS_GEO -> handleGeoUpdate(context, intent)
else -> Log.w(TAG, "Unknown intent action: ${intent.action}!")
}
}
@ -61,9 +68,7 @@ class LocationBroadcastReceiver : BroadcastReceiver() {
}
}
private fun requestUpdates(context: Context) {
Log.d(TAG, "Registering for location updates.")
private fun setupLocationTracking(context: Context) {
if (ActivityCompat.checkSelfPermission(
context,
ACCESS_COARSE_LOCATION
@ -73,48 +78,88 @@ class LocationBroadcastReceiver : BroadcastReceiver() {
return
}
mainScope.launch {
if (integrationUseCase.isBackgroundTrackingEnabled())
requestLocationUpdates(context)
if (integrationUseCase.isZoneTrackingEnabled())
requestZoneUpdates(context)
}
}
private fun requestLocationUpdates(context: Context) {
Log.d(TAG, "Registering for location updates.")
val fusedLocationProviderClient = LocationServices.getFusedLocationProviderClient(context)
val intent = getLocationUpdateIntent(context, false)
fusedLocationProviderClient.removeLocationUpdates(intent)
fusedLocationProviderClient.requestLocationUpdates(
createLocationRequest(),
getLocationUpdateIntent(context)
intent
)
}
private fun handleUpdate(context: Context, intent: Intent) {
private suspend fun requestZoneUpdates(context: Context) {
Log.d(TAG, "Registering for zone based location updates")
val geofencingClient = LocationServices.getGeofencingClient(context)
val intent = getLocationUpdateIntent(context, true)
geofencingClient.removeGeofences(intent)
geofencingClient.addGeofences(
createGeofencingRequest(),
intent
)
}
private fun handleLocationUpdate(context: Context, intent: Intent) {
Log.d(TAG, "Received location update.")
LocationResult.extractResult(intent)?.lastLocation?.let {
sendLocationUpdate(it, context)
}
}
Log.d(
TAG, "Last Location: " +
"\nCoords:(${it.latitude}, ${it.longitude})" +
"\nAccuracy: ${it.accuracy}" +
"\nBearing: ${it.bearing}"
)
val updateLocation = UpdateLocation(
"",
arrayOf(it.latitude, it.longitude),
it.accuracy.toInt(),
getBatteryLevel(context),
it.speed.toInt(),
it.altitude.toInt(),
it.bearing.toInt(),
if (Build.VERSION.SDK_INT >= 26) it.verticalAccuracyMeters.toInt() else 0
)
private fun handleGeoUpdate(context: Context, intent: Intent) {
Log.d(TAG, "Received geofence update.")
val geofencingEvent = GeofencingEvent.fromIntent(intent)
if (geofencingEvent.hasError()) {
Log.e(TAG, "Error getting geofence broadcast status code: ${geofencingEvent.errorCode}")
return
}
mainScope.launch {
try {
integrationUseCase.updateLocation(updateLocation)
} catch (e: Exception) {
Log.e(TAG, "Could not update location.", e)
}
sendLocationUpdate(geofencingEvent.triggeringLocation, context)
}
private fun sendLocationUpdate(location: Location, context: Context) {
Log.d(
TAG, "Last Location: " +
"\nCoords:(${location.latitude}, ${location.longitude})" +
"\nAccuracy: ${location.accuracy}" +
"\nBearing: ${location.bearing}"
)
val updateLocation = UpdateLocation(
"",
arrayOf(location.latitude, location.longitude),
location.accuracy.toInt(),
getBatteryLevel(context),
location.speed.toInt(),
location.altitude.toInt(),
location.bearing.toInt(),
if (Build.VERSION.SDK_INT >= 26) location.verticalAccuracyMeters.toInt() else 0
)
mainScope.launch {
try {
integrationUseCase.updateLocation(updateLocation)
} catch (e: Exception) {
Log.e(TAG, "Could not update location.", e)
}
}
}
private fun getLocationUpdateIntent(context: Context): PendingIntent {
private fun getLocationUpdateIntent(context: Context, isGeofence: Boolean): PendingIntent {
val intent = Intent(context, LocationBroadcastReceiver::class.java)
intent.action = ACTION_PROCESS_LOCATION
intent.action = if (isGeofence) ACTION_PROCESS_GEO else ACTION_PROCESS_LOCATION
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
@ -130,6 +175,22 @@ class LocationBroadcastReceiver : BroadcastReceiver() {
return locationRequest
}
private suspend fun createGeofencingRequest(): GeofencingRequest {
val geofencingRequestBuilder = GeofencingRequest.Builder()
integrationUseCase.getZones().forEach {
geofencingRequestBuilder.addGeofence(Geofence.Builder()
.setRequestId(it.entityId)
.setCircularRegion(
it.attributes.latitude,
it.attributes.longitude,
it.attributes.radius)
.setExpirationDuration(Geofence.NEVER_EXPIRE)
.setTransitionTypes(Geofence.GEOFENCE_TRANSITION_ENTER or Geofence.GEOFENCE_TRANSITION_EXIT)
.build())
}
return geofencingRequestBuilder.build()
}
private fun getBatteryLevel(context: Context): Int? {
val batteryIntent =
context.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED))

View file

@ -1,25 +1,17 @@
package io.homeassistant.companion.android.onboarding.integration
import android.Manifest.permission.ACCESS_BACKGROUND_LOCATION
import android.Manifest.permission.ACCESS_COARSE_LOCATION
import android.Manifest.permission.ACCESS_FINE_LOCATION
import android.annotation.SuppressLint
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.ViewFlipper
import androidx.core.app.ActivityCompat
import androidx.fragment.app.Fragment
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.background.LocationBroadcastReceiver
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.util.PermissionManager
import javax.inject.Inject
class MobileAppIntegrationFragment : Fragment(), MobileAppIntegrationView {
@ -28,8 +20,6 @@ class MobileAppIntegrationFragment : Fragment(), MobileAppIntegrationView {
private const val LOADING_VIEW = 0
private const val ERROR_VIEW = 1
private const val LOCATION_REQUEST_CODE = 1000
fun newInstance(): MobileAppIntegrationFragment {
return MobileAppIntegrationFragment()
}
@ -70,11 +60,8 @@ class MobileAppIntegrationFragment : Fragment(), MobileAppIntegrationView {
}
override fun deviceRegistered() {
if (!haveLocationPermission()) {
requestPermissions(
getLocationPermissions(),
LOCATION_REQUEST_CODE
)
if (!PermissionManager.haveLocationPermissions(context!!)) {
PermissionManager.requestLocationPermissions(this)
} else {
// If we have permission already we can just continue with
(activity as MobileAppIntegrationListener).onIntegrationRegistrationComplete()
@ -105,36 +92,10 @@ class MobileAppIntegrationFragment : Fragment(), MobileAppIntegrationView {
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == LOCATION_REQUEST_CODE &&
grantResults.all { it == PackageManager.PERMISSION_GRANTED }
) {
val intent = Intent(context, LocationBroadcastReceiver::class.java)
intent.action = LocationBroadcastReceiver.ACTION_REQUEST_LOCATION_UPDATES
activity!!.sendBroadcast(intent)
if (PermissionManager.validateLocationPermissions(requestCode, permissions, grantResults)) {
presenter.onGrantedLocationPermission(context!!, activity!!)
}
(activity as MobileAppIntegrationListener).onIntegrationRegistrationComplete()
}
private fun haveLocationPermission(): Boolean {
return ActivityCompat.checkSelfPermission(
context!!,
ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED &&
ActivityCompat.checkSelfPermission(
context!!,
ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
@SuppressLint("InlinedApi")
private fun getLocationPermissions(): Array<String> {
var retVal = arrayOf(ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION)
if (Build.VERSION.SDK_INT >= 21)
retVal = retVal.plus(ACCESS_BACKGROUND_LOCATION)
return retVal
}
}

View file

@ -1,7 +1,11 @@
package io.homeassistant.companion.android.onboarding.integration
import android.app.Activity
import android.content.Context
interface MobileAppIntegrationPresenter {
fun onRegistrationAttempt()
fun onGrantedLocationPermission(context: Context, activity: Activity)
fun onSkip()
fun onFinish()
}

View file

@ -1,10 +1,13 @@
package io.homeassistant.companion.android.onboarding.integration
import android.app.Activity
import android.content.Context
import android.os.Build
import android.util.Log
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.domain.integration.DeviceRegistration
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import io.homeassistant.companion.android.util.PermissionManager
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -50,6 +53,14 @@ class MobileAppIntegrationPresenterImpl @Inject constructor(
}
}
override fun onGrantedLocationPermission(context: Context, activity: Activity) {
mainScope.launch {
integrationUseCase.setZoneTrackingEnabled(true)
integrationUseCase.setBackgroundTrackingEnabled(true)
PermissionManager.restartLocationTracking(context, activity)
}
}
override fun onSkip() {
view.registrationSkipped()
}

View file

@ -3,9 +3,7 @@ package io.homeassistant.companion.android.settings
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.R
class SettingsActivity : AppCompatActivity() {
@ -19,7 +17,11 @@ class SettingsActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
findViewById<TextView>(R.id.version_text_view).text =
"${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
setSupportActionBar(findViewById(R.id.toolbar))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportFragmentManager
.beginTransaction()
.replace(R.id.content, SettingsFragment.newInstance())
.commit()
}
}

View file

@ -0,0 +1,63 @@
package io.homeassistant.companion.android.settings
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.preference.SwitchPreference
import io.homeassistant.companion.android.BuildConfig
import io.homeassistant.companion.android.DaggerPresenterComponent
import io.homeassistant.companion.android.PresenterModule
import io.homeassistant.companion.android.R
import io.homeassistant.companion.android.common.dagger.GraphComponentAccessor
import io.homeassistant.companion.android.util.PermissionManager
import javax.inject.Inject
class SettingsFragment : PreferenceFragmentCompat(), SettingsView {
@Inject
lateinit var presenter: SettingsPresenter
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
DaggerPresenterComponent
.builder()
.appComponent((activity?.application as GraphComponentAccessor).appComponent)
.presenterModule(PresenterModule(this))
.build()
.inject(this)
preferenceManager.preferenceDataStore = presenter.getPreferenceDataStore()
setPreferencesFromResource(R.xml.preferences, rootKey)
findPreference<Preference>("version").let {
it!!.summary = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
}
}
override fun onLocationSettingChanged() {
if (!PermissionManager.haveLocationPermissions(context!!)) {
PermissionManager.requestLocationPermissions(this)
}
PermissionManager.restartLocationTracking(context!!, activity!!)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (PermissionManager.validateLocationPermissions(requestCode, permissions, grantResults)) {
PermissionManager.restartLocationTracking(context!!, activity!!)
} else {
// If we don't have permissions, don't let them in!
findPreference<SwitchPreference>("location_zone")!!.isChecked = false
findPreference<SwitchPreference>("location_background")!!.isChecked = false
}
}
companion object {
fun newInstance() = SettingsFragment()
}
}

View file

@ -0,0 +1,8 @@
package io.homeassistant.companion.android.settings
import androidx.preference.PreferenceDataStore
interface SettingsPresenter {
fun getPreferenceDataStore(): PreferenceDataStore
fun onFinish()
}

View file

@ -0,0 +1,48 @@
package io.homeassistant.companion.android.settings
import androidx.preference.PreferenceDataStore
import io.homeassistant.companion.android.domain.integration.IntegrationUseCase
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
class SettingsPresenterImpl @Inject constructor(
private val settingsView: SettingsView,
private val integrationUseCase: IntegrationUseCase
) : SettingsPresenter, PreferenceDataStore() {
private val mainScope: CoroutineScope = CoroutineScope(Dispatchers.Main + Job())
override fun getBoolean(key: String?, defValue: Boolean): Boolean {
return runBlocking {
return@runBlocking when (key) {
"location_zone" -> integrationUseCase.isZoneTrackingEnabled()
"location_background" -> integrationUseCase.isBackgroundTrackingEnabled()
else -> throw Exception()
}
}
}
override fun putBoolean(key: String?, value: Boolean) {
mainScope.launch {
when (key) {
"location_zone" -> integrationUseCase.setZoneTrackingEnabled(value)
"location_background" -> integrationUseCase.setBackgroundTrackingEnabled(value)
else -> throw Exception()
}
settingsView.onLocationSettingChanged()
}
}
override fun getPreferenceDataStore(): PreferenceDataStore {
return this
}
override fun onFinish() {
mainScope.cancel()
}
}

View file

@ -0,0 +1,5 @@
package io.homeassistant.companion.android.settings
interface SettingsView {
fun onLocationSettingChanged()
}

View file

@ -0,0 +1,62 @@
package io.homeassistant.companion.android.util
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.ActivityCompat
import androidx.fragment.app.Fragment
import io.homeassistant.companion.android.background.LocationBroadcastReceiver
class PermissionManager {
companion object {
private const val LOCATION_REQUEST_CODE = 1
fun haveLocationPermissions(context: Context): Boolean {
return ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_COARSE_LOCATION
) == PackageManager.PERMISSION_GRANTED &&
ActivityCompat.checkSelfPermission(
context,
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED
}
@SuppressLint("InlinedApi")
fun getLocationPermissionArray(): Array<String> {
var retVal = arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION,
Manifest.permission.ACCESS_FINE_LOCATION
)
if (Build.VERSION.SDK_INT >= 21)
retVal = retVal.plus(Manifest.permission.ACCESS_BACKGROUND_LOCATION)
return retVal
}
fun validateLocationPermissions(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
): Boolean {
return requestCode == LOCATION_REQUEST_CODE && grantResults.all { it == PackageManager.PERMISSION_GRANTED }
}
fun requestLocationPermissions(fragment: Fragment) {
fragment.requestPermissions(getLocationPermissionArray(), LOCATION_REQUEST_CODE)
}
fun restartLocationTracking(context: Context, activity: Activity) {
val intent = Intent(context, LocationBroadcastReceiver::class.java)
intent.action = LocationBroadcastReceiver.ACTION_REQUEST_LOCATION_UPDATES
activity.sendBroadcast(intent)
}
}
}

View file

@ -0,0 +1,8 @@
<!-- drawable/map-marker.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@color/colorAccent" android:pathData="M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2Z" />
</vector>

View file

@ -0,0 +1,8 @@
<!-- drawable/map-marker-radius.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:width="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path android:fillColor="@color/colorAccent" android:pathData="M12,2C15.31,2 18,4.66 18,7.95C18,12.41 12,19 12,19C12,19 6,12.41 6,7.95C6,4.66 8.69,2 12,2M12,6A2,2 0 0,0 10,8A2,2 0 0,0 12,10A2,2 0 0,0 14,8A2,2 0 0,0 12,6M20,19C20,21.21 16.42,23 12,23C7.58,23 4,21.21 4,19C4,17.71 5.22,16.56 7.11,15.83L7.75,16.74C6.67,17.19 6,17.81 6,18.5C6,19.88 8.69,21 12,21C15.31,21 18,19.88 18,18.5C18,17.81 17.33,17.19 16.25,16.74L16.89,15.83C18.78,16.56 20,17.71 20,19Z" />
</vector>

View file

@ -1,39 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/title"
style="@style/TextAppearance.HomeAssistant.Headline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="36dp"
android:text="@string/app_name"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/icon"
android:layout_width="75dp"
android:layout_height="75dp"
android:layout_marginTop="16dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@+id/title"
app:srcCompat="@drawable/app_icon" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/version_text_view"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toBottomOf="@id/icon"
tools:text="1.0.0 (1)" />
</androidx.constraintlayout.widget.ConstraintLayout>
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:titleTextColor="@android:color/white"
android:elevation="4dp" />
<FrameLayout
android:id="@+id/content"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>

View file

@ -1,23 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Ricerca Home Assistant in corso.
Scansione della rete</string>
<string name="home_assistant_not_discover">Impossibile trovate
il tuo Home Assistant</string>
<string name="connect_to_home_internet">Assicurati che il telefono sia collegato
a Internet di casa tua.</string>
<string name="scan_again">Cerca di nuovo</string>
<string name="manual_setup">inserisci l\'indirizzo manualmente</string>
<string name="input_url">URL Home Assistant</string>
<string name="input_url_hint">https://esempio.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Stato integrazione della mobile app:</string>
<string name="checking_with_home_assistant">Verifica Home Assistant in corso</string>
<string name="skip">Salta</string>
<string name="retry">Riprova</string>
<string name="attempting_registration">Tentativo di registrazione dell\'applicazione...</string>
<string name="unable_to_register">Impossibile registrare l\'applicazione</string>
<string name="error_with_registration">Varifica di avere l\'integrazione mobile_app abilitata
sul tuo home assistant.</string>
<string name="url_parse_error">Impossibile analizzare l\'URL di Home Assistant. Dovrebbe somigliare a https://esempio.com</string>
</resources>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Netwerk scannen naar Home Assistant</string>
<string name="home_assistant_not_discover">Home Assistant instantie niet gevonden</string>
<string name="connect_to_home_internet">Je telefoon dient verbonden te zijn met je thuis netwerk.</string>
<string name="scan_again">opnieuw scannen</string>
<string name="manual_setup">Adres handmatig invullen</string>
<string name="input_url">Home Assistant adres</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Status van mobiele app integratie:</string>
<string name="checking_with_home_assistant">Controleren met Home Assistant</string>
<string name="skip">Overslaan</string>
<string name="retry">Opnieuw proberen</string>
<string name="attempting_registration">Proberen om de applicatie te registreren...</string>
<string name="unable_to_register">Niet mogelijk om de applicatie te registeren.</string>
<string name="error_with_registration">Controleer of de mobile_app integratie is ingeschakeld in je Home Assistant instantie.</string>
<string name="url_parse_error">Je Home Assistant URL lijkt niet te kloppen. Het zou ongeveer hetzelfde moeten zijn als https://example.com</string>
</resources>

View file

@ -1,19 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Skanowanie sieci w poszukiwaniu Home Assistant\'a</string>
<string name="home_assistant_not_discover">Nie można znaleźć Twojego Home Assistant\'a</string>
<string name="connect_to_home_internet">Upewnij się, że telefon jest podłączony do sieci domowej.</string>
<string name="scan_again">skanuj ponownie</string>
<string name="manual_setup">wprowadź adres ręcznie</string>
<string name="input_url">Adres URL Home Assistant\'a</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Status integracji mobile_app:</string>
<string name="checking_with_home_assistant">Sprawdzanie z Home Assistant\'em</string>
<string name="skip">Pomiń</string>
<string name="retry">Ponów próbę</string>
<string name="attempting_registration">Próba zarejestrowania aplikacji…</string>
<string name="unable_to_register">Nie można zarejestrować aplikacji</string>
<string name="error_with_registration">Sprawdź, czy integracja mobile_app jest włączona w Twoim instancji Home Assistant\'a.</string>
<string name="url_parse_error">Nie można rozpoznać adresu URL Home Assistant\'a. Powinien on wyglądać następująco https://example.com</string>
</resources>

View file

@ -1,20 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Se caută o instanță a Home Assistant-ul in rețeaua locală </string>
<string name="home_assistant_not_discover">Nu am găsit instanța Home Assistant-ului</string>
<string name="connect_to_home_internet">Asigurați-vă că telefonul dvs. este conectat
la rețeaua locală.</string>
<string name="scan_again">scaneaza inca o data</string>
<string name="manual_setup">ntroduceți manual adresa</string>
<string name="input_url">Adresa Home Assistant-</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Statusul integrării a l aplicatiei mobile.</string>
<string name="checking_with_home_assistant">Verific cu Home Assistant-ul</string>
<string name="skip">Nu acum </string>
<string name="retry">Reîncercați</string>
<string name="attempting_registration">Se încearcă înregistrarea aplicației...</string>
<string name="unable_to_register">Nu s-au putut înregistra </string>
<string name="error_with_registration">Te rog verifică dacă este activată integrarea pentru mobile_app în configurația Home Assistant-ului.</string>
<string name="url_parse_error">Nu am putut analiza adresa Home Assistant-ului. Ar trebui sa arate așa: https:///example.com</string>
</resources>

View file

@ -2,4 +2,5 @@
<resources>
<color name="colorPrimary">#03A9F4</color>
<color name="colorPrimaryDark">#0288D1</color>
<color name="colorAccent">#03A9F4</color>
</resources>

View file

@ -1,23 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<resources>
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Scanning the network
<string name="app_name">Home Assistant</string>
<string name="scanning_network">Scanning the network
for Home Assistant</string>
<string name="home_assistant_not_discover">Unable to find your
<string name="home_assistant_not_discover">Unable to find your
Home Assistant instance</string>
<string name="connect_to_home_internet">Make sure your phone is connected
<string name="connect_to_home_internet">Make sure your phone is connected
to your home internet.</string>
<string name="scan_again">scan again</string>
<string name="manual_setup">enter address manually</string>
<string name="input_url">Home Assistant URL</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Status of mobile app integration:</string>
<string name="checking_with_home_assistant">Checking with Home Assistant</string>
<string name="skip">Skip</string>
<string name="retry">Retry</string>
<string name="attempting_registration">Attempting to register application…</string>
<string name="unable_to_register">Unable to Register Application</string>
<string name="error_with_registration">Please check to ensure you have the mobile_app
<string name="scan_again">scan again</string>
<string name="manual_setup">enter address manually</string>
<string name="input_url">Home Assistant URL</string>
<string name="input_url_hint">https://example.duckdns.org:8123</string>
<string name="status_of_mobile_app_integration">Status of mobile app integration:</string>
<string name="checking_with_home_assistant">Checking with Home Assistant</string>
<string name="skip">Skip</string>
<string name="retry">Retry</string>
<string name="attempting_registration">Attempting to register application…</string>
<string name="unable_to_register">Unable to Register Application</string>
<string name="error_with_registration">Please check to ensure you have the mobile_app
integration enabled on your home assistant instance.</string>
<string name="url_parse_error">Unable to parse your Home Assistant URL. It should look like https://example.com</string>
<string name="url_parse_error">Unable to parse your Home Assistant URL. It should look like https://example.com</string>
<string name="pref_location_zone_title">Zone Based Tracking</string>
<string name="pref_location_zone_summary">Import existing Home Assistant zones as geofences for zone based tracking.</string>
<string name="pref_location_background_title">Background Location Tracking</string>
<string name="pref_location_background_summary">Update your location behind the scenes, periodically.</string>
<string name="application_version">Application Version</string>
</resources>

View file

@ -3,8 +3,11 @@
<style name="Theme.HomeAssistant" parent="Theme.AppCompat.Light.NoActionBar">
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="colorButtonNormal">@color/colorPrimary</item>
<item name="colorControlNormal">@android:color/white</item>
<item name="buttonStyle">@style/Widget.HomeAssistant.Button.Colored</item>
<item name="alertDialogTheme">@style/Theme.HomeAssistant.Dialog.Alert</item>

View file

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<PreferenceCategory
android:title="Location">
<SwitchPreference
android:key="location_zone"
android:icon="@drawable/map_marker_radius"
android:title="@string/pref_location_zone_title"
android:summary="@string/pref_location_zone_summary"/>
<SwitchPreference
android:key="location_background"
android:icon="@drawable/map_marker"
android:title="@string/pref_location_background_title"
android:summary="@string/pref_location_background_summary"/>
</PreferenceCategory>
<PreferenceCategory
android:title="App Version Info">
<Preference
android:key="version"
android:icon="@drawable/app_icon"
android:title="@string/application_version"
android:summary="1.0.0 (1)"/>
</PreferenceCategory>
</PreferenceScreen>

View file

@ -28,4 +28,12 @@ class LocalStorageImpl(private val sharedPreferences: SharedPreferences) : Local
null
}
}
override suspend fun putBoolean(key: String, value: Boolean) {
sharedPreferences.edit().putBoolean(key, value).apply()
}
override suspend fun getBoolean(key: String): Boolean {
return sharedPreferences.getBoolean(key, false)
}
}

View file

@ -9,4 +9,8 @@ interface LocalStorage {
suspend fun putLong(key: String, value: Long?)
suspend fun getLong(key: String): Long?
suspend fun putBoolean(key: String, value: Boolean)
suspend fun getBoolean(key: String): Boolean
}

View file

@ -0,0 +1,12 @@
package io.homeassistant.companion.android.data.integration
import java.util.Calendar
data class EntityResponse<T>(
val entityId: String,
val state: String,
val attributes: T,
val lastChanged: Calendar,
val lastUpdated: Calendar,
val context: Map<String, Any>
)

View file

@ -3,8 +3,10 @@ package io.homeassistant.companion.android.data.integration
import io.homeassistant.companion.android.data.LocalStorage
import io.homeassistant.companion.android.domain.authentication.AuthenticationRepository
import io.homeassistant.companion.android.domain.integration.DeviceRegistration
import io.homeassistant.companion.android.domain.integration.Entity
import io.homeassistant.companion.android.domain.integration.IntegrationRepository
import io.homeassistant.companion.android.domain.integration.UpdateLocation
import io.homeassistant.companion.android.domain.integration.ZoneAttributes
import javax.inject.Inject
import javax.inject.Named
import okhttp3.HttpUrl
@ -21,6 +23,9 @@ class IntegrationRepositoryImpl @Inject constructor(
private const val PREF_REMOTE_UI_URL = "remote_ui_url"
private const val PREF_SECRET = "secret"
private const val PREF_WEBHOOK_ID = "webhook_id"
private const val PREF_ZONE_ENABLED = "zone_enabled"
private const val PREF_BACKGROUND_ENABLED = "background_enabled"
}
override suspend fun registerDevice(deviceRegistration: DeviceRegistration) {
@ -45,7 +50,8 @@ class IntegrationRepositoryImpl @Inject constructor(
for (it in getUrls()) {
var wasSuccess = false
try {
wasSuccess = integrationService.updateLocation(it, updateLocationRequest).isSuccessful
wasSuccess =
integrationService.updateLocation(it, updateLocationRequest).isSuccessful
} catch (e: Exception) {
// Ignore failure until we are out of URLS to try!
}
@ -57,6 +63,40 @@ class IntegrationRepositoryImpl @Inject constructor(
throw IntegrationException()
}
override suspend fun getZones(): Array<Entity<ZoneAttributes>> {
val getZonesRequest = IntegrationRequest("get_zones", null)
var zones: Array<EntityResponse<ZoneAttributes>>? = null
for (it in getUrls()) {
try {
zones = integrationService.getZones(it, getZonesRequest)
} catch (e: Exception) {
// Ignore failure until we are out of URLS to try!
}
if (zones != null) {
return createZonesResponse(zones)
}
}
throw IntegrationException()
}
override suspend fun setZoneTrackingEnabled(enabled: Boolean) {
localStorage.putBoolean(PREF_ZONE_ENABLED, enabled)
}
override suspend fun isZoneTrackingEnabled(): Boolean {
return localStorage.getBoolean(PREF_ZONE_ENABLED)
}
override suspend fun setBackgroundTrackingEnabled(enabled: Boolean) {
localStorage.putBoolean(PREF_BACKGROUND_ENABLED, enabled)
}
override suspend fun isBackgroundTrackingEnabled(): Boolean {
return localStorage.getBoolean(PREF_BACKGROUND_ENABLED)
}
// https://developers.home-assistant.io/docs/en/app_integration_sending_data.html#short-note-on-instance-urls
private suspend fun getUrls(): Array<HttpUrl> {
val retVal = ArrayList<HttpUrl>()
@ -115,4 +155,22 @@ class IntegrationRepositoryImpl @Inject constructor(
)
)
}
private fun createZonesResponse(zones: Array<EntityResponse<ZoneAttributes>>): Array<Entity<ZoneAttributes>> {
val retVal = ArrayList<Entity<ZoneAttributes>>()
zones.forEach {
retVal.add(
Entity<ZoneAttributes>(
it.entityId,
it.state,
it.attributes,
it.lastChanged,
it.lastUpdated,
it.context
)
)
}
return retVal.toTypedArray()
}
}

View file

@ -5,5 +5,5 @@ import com.fasterxml.jackson.annotation.JsonInclude
data class IntegrationRequest(
val type: String,
@JsonInclude(JsonInclude.Include.NON_NULL)
val data: Any
val data: Any?
)

View file

@ -1,5 +1,6 @@
package io.homeassistant.companion.android.data.integration
import io.homeassistant.companion.android.domain.integration.ZoneAttributes
import okhttp3.HttpUrl
import okhttp3.ResponseBody
import retrofit2.Response
@ -21,4 +22,10 @@ interface IntegrationService {
@Url url: HttpUrl,
@Body request: IntegrationRequest
): Response<ResponseBody>
@POST
suspend fun getZones(
@Url url: HttpUrl,
@Body request: IntegrationRequest
): Array<EntityResponse<ZoneAttributes>>
}

View file

@ -3,13 +3,17 @@ package io.homeassistant.companion.android.data.integration
import io.homeassistant.companion.android.data.LocalStorage
import io.homeassistant.companion.android.domain.authentication.AuthenticationRepository
import io.homeassistant.companion.android.domain.integration.DeviceRegistration
import io.homeassistant.companion.android.domain.integration.Entity
import io.homeassistant.companion.android.domain.integration.UpdateLocation
import io.homeassistant.companion.android.domain.integration.ZoneAttributes
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyAll
import io.mockk.every
import io.mockk.mockk
import java.net.URL
import java.util.Calendar
import java.util.HashMap
import kotlin.properties.Delegates
import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl
@ -417,5 +421,88 @@ object IntegrationRepositoryImplSpec : Spek({
}
}
}
describe("get zones") {
beforeEachTest {
coEvery { localStorage.getString("cloud_url") } returns "https://best.com"
coEvery { localStorage.getString("remote_ui_url") } returns "http://better.com/"
coEvery { authenticationRepository.getUrl() } returns URL("http://example.com")
coEvery { localStorage.getString("webhook_id") } returns "FGHIJ"
}
describe("getZones") {
val entities = EntityResponse(
"entityId",
"state",
ZoneAttributes(
false,
0.0,
1.1,
2.2F,
"fName",
"icon"
),
Calendar.getInstance(),
Calendar.getInstance(),
HashMap()
)
var zones: Array<Entity<ZoneAttributes>>? = null
beforeEachTest {
coEvery { integrationService.getZones(any(), any()) } returns arrayOf(entities)
runBlocking { zones = repository.getZones() }
}
it("should return true when webhook has a value") {
assertThat(zones).isNotNull
assertThat(zones!!.size).isEqualTo(1)
assertThat(zones!![0]).isNotNull
assertThat(zones!![0].entityId).isEqualTo(entities.entityId)
}
}
}
describe("location settings") {
describe("isZoneTrackingEnabled") {
var isZoneTrackingEnabled by Delegates.notNull<Boolean>()
beforeEachTest {
coEvery { localStorage.getBoolean("zone_enabled") } returns true
runBlocking { isZoneTrackingEnabled = repository.isZoneTrackingEnabled() }
}
it("should return what is stored") {
assertThat(isZoneTrackingEnabled).isTrue()
}
}
describe("setZoneTrackingEnabled") {
beforeEachTest {
runBlocking { repository.setZoneTrackingEnabled(true) }
}
it("should return what is stored") {
coVerify {
localStorage.putBoolean("zone_enabled", true)
}
}
}
describe("isBackgroundTrackingEnabled") {
var isBackgroundTrackingEnabled by Delegates.notNull<Boolean>()
beforeEachTest {
coEvery { localStorage.getBoolean("background_enabled") } returns true
runBlocking { isBackgroundTrackingEnabled = repository.isBackgroundTrackingEnabled() }
}
it("should return what is stored") {
assertThat(isBackgroundTrackingEnabled).isTrue()
}
}
describe("setBackgroundTrackingEnabled") {
beforeEachTest {
runBlocking { repository.setBackgroundTrackingEnabled(true) }
}
it("should return what is stored") {
coVerify {
localStorage.putBoolean("background_enabled", true)
}
}
}
}
}
})

View file

@ -0,0 +1,12 @@
package io.homeassistant.companion.android.domain.integration
import java.util.Calendar
data class Entity<T>(
val entityId: String,
val state: String,
val attributes: T,
val lastChanged: Calendar,
val lastUpdated: Calendar,
val context: Map<String, Any>
)

View file

@ -7,4 +7,12 @@ interface IntegrationRepository {
suspend fun isRegistered(): Boolean
suspend fun updateLocation(updateLocation: UpdateLocation)
suspend fun getZones(): Array<Entity<ZoneAttributes>>
suspend fun setZoneTrackingEnabled(enabled: Boolean)
suspend fun isZoneTrackingEnabled(): Boolean
suspend fun setBackgroundTrackingEnabled(enabled: Boolean)
suspend fun isBackgroundTrackingEnabled(): Boolean
}

View file

@ -7,4 +7,14 @@ interface IntegrationUseCase {
suspend fun isRegistered(): Boolean
suspend fun updateLocation(updateLocation: UpdateLocation)
suspend fun getZones(): Array<Entity<ZoneAttributes>>
suspend fun setZoneTrackingEnabled(enabled: Boolean)
suspend fun isZoneTrackingEnabled(): Boolean
suspend fun setBackgroundTrackingEnabled(enabled: Boolean)
suspend fun isBackgroundTrackingEnabled(): Boolean
}

View file

@ -16,4 +16,24 @@ class IntegrationUseCaseImpl @Inject constructor(
override suspend fun updateLocation(updateLocation: UpdateLocation) {
return integrationRepository.updateLocation(updateLocation)
}
override suspend fun getZones(): Array<Entity<ZoneAttributes>> {
return integrationRepository.getZones()
}
override suspend fun setZoneTrackingEnabled(enabled: Boolean) {
return integrationRepository.setZoneTrackingEnabled(enabled)
}
override suspend fun isZoneTrackingEnabled(): Boolean {
return integrationRepository.isZoneTrackingEnabled()
}
override suspend fun setBackgroundTrackingEnabled(enabled: Boolean) {
return integrationRepository.setBackgroundTrackingEnabled(enabled)
}
override suspend fun isBackgroundTrackingEnabled(): Boolean {
return integrationRepository.isBackgroundTrackingEnabled()
}
}

View file

@ -0,0 +1,10 @@
package io.homeassistant.companion.android.domain.integration
data class ZoneAttributes(
val hidden: Boolean,
val latitude: Double,
val longitude: Double,
val radius: Float,
val friendlyName: String,
val icon: String
)

View file

@ -59,5 +59,55 @@ object IntegrationUseCaseImplSpec : Spek({
coVerify { integrationRepository.updateLocation(location) }
}
}
describe("getZones") {
beforeEachTest {
runBlocking { useCase.getZones() }
}
it("should call the repository") {
coVerify { integrationRepository.getZones() }
}
}
describe("setZoneTrackingEnabled") {
beforeEachTest {
runBlocking { useCase.setZoneTrackingEnabled(true) }
}
it("should call the repository") {
coVerify { integrationRepository.setZoneTrackingEnabled(true) }
}
}
describe("isZoneTrackingEnabled") {
beforeEachTest {
runBlocking { useCase.isZoneTrackingEnabled() }
}
it("should call the repository") {
coVerify { integrationRepository.isZoneTrackingEnabled() }
}
}
describe("setBackgroundTrackingEnabled") {
beforeEachTest {
runBlocking { useCase.setBackgroundTrackingEnabled(true) }
}
it("should call the repository") {
coVerify { integrationRepository.setBackgroundTrackingEnabled(true) }
}
}
describe("isBackgroundTrackingEnabled") {
beforeEachTest {
runBlocking { useCase.isBackgroundTrackingEnabled() }
}
it("should call the repository") {
coVerify { integrationRepository.isBackgroundTrackingEnabled() }
}
}
}
})