From 7ebf5f7c18419acb7b805936d1ea8add442d58b9 Mon Sep 17 00:00:00 2001 From: estevez-dev Date: Fri, 26 Jun 2020 12:06:06 +0300 Subject: [PATCH] WIP #571 Native foreground servise with active location tracking --- android/app/src/main/AndroidManifest.xml | 4 + .../hassclient/LocationUpdatesService.java | 358 ++++++++++++++++++ .../hassclient/MainActivity.java | 200 +++++++++- .../com/keyboardcrumbs/hassclient/Utils.java | 46 +++ .../settings/integration_settings.part.dart | 35 +- 5 files changed, 640 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/java/com/keyboardcrumbs/hassclient/LocationUpdatesService.java create mode 100644 android/app/src/main/java/com/keyboardcrumbs/hassclient/Utils.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4193831..9385878 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -56,6 +56,10 @@ + diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/LocationUpdatesService.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/LocationUpdatesService.java new file mode 100644 index 0000000..5166829 --- /dev/null +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/LocationUpdatesService.java @@ -0,0 +1,358 @@ +package com.keyboardcrumbs.hassclient; + +import android.app.ActivityManager; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.res.Configuration; +import android.location.Location; +import android.os.Binder; +import android.os.Build; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.IBinder; +import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import android.util.Log; + +import com.google.android.gms.location.FusedLocationProviderClient; +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.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; + +/** + * A bound and started service that is promoted to a foreground service when location updates have + * been requested and all clients unbind. + * + * For apps running in the background on "O" devices, location is computed only once every 10 + * minutes and delivered batched every 30 minutes. This restriction applies even to apps + * targeting "N" or lower which are run on "O" devices. + * + * This sample show how to use a long-running service for location updates. When an activity is + * bound to this service, frequent location updates are permitted. When the activity is removed + * from the foreground, the service promotes itself to a foreground service, and location updates + * continue. When the activity comes back to the foreground, the foreground service stops, and the + * notification assocaited with that service is removed. + */ +public class LocationUpdatesService extends Service { + + private static final String PACKAGE_NAME = + "com.google.android.gms.location.sample.locationupdatesforegroundservice"; + + private static final String TAG = LocationUpdatesService.class.getSimpleName(); + + /** + * The name of the channel for notifications. + */ + private static final String CHANNEL_ID = "channel_01"; + + static final String ACTION_BROADCAST = PACKAGE_NAME + ".broadcast"; + + static final String EXTRA_LOCATION = PACKAGE_NAME + ".location"; + private static final String EXTRA_STARTED_FROM_NOTIFICATION = PACKAGE_NAME + + ".started_from_notification"; + + private final IBinder mBinder = new LocalBinder(); + + /** + * The desired interval for location updates. Inexact. Updates may be more or less frequent. + */ + private static final long UPDATE_INTERVAL_IN_MILLISECONDS = 10000; + + /** + * The fastest rate for active location updates. Updates will never be more frequent + * than this value. + */ + private static final long FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS = + UPDATE_INTERVAL_IN_MILLISECONDS / 2; + + /** + * The identifier for the notification displayed for the foreground service. + */ + private static final int NOTIFICATION_ID = 954311; + + /** + * Used to check whether the bound activity has really gone away and not unbound as part of an + * orientation change. We create a foreground service notification only if the former takes + * place. + */ + private boolean mChangingConfiguration = false; + + private NotificationManager mNotificationManager; + + private LocationRequest mLocationRequest; + + /** + * Provides access to the Fused Location Provider API. + */ + private FusedLocationProviderClient mFusedLocationClient; + + /** + * Callback for changes in location. + */ + private LocationCallback mLocationCallback; + + private Handler mServiceHandler; + + /** + * The current location. + */ + private Location mLocation; + + public LocationUpdatesService() { + } + + @Override + public void onCreate() { + mFusedLocationClient = LocationServices.getFusedLocationProviderClient(this); + + mLocationCallback = new LocationCallback() { + @Override + public void onLocationResult(LocationResult locationResult) { + super.onLocationResult(locationResult); + onNewLocation(locationResult.getLastLocation()); + } + }; + + createLocationRequest(); + getLastLocation(); + + HandlerThread handlerThread = new HandlerThread(TAG); + handlerThread.start(); + mServiceHandler = new Handler(handlerThread.getLooper()); + mNotificationManager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + + // Android O requires a Notification Channel. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = "Location service"; + // Create the channel for the notification + NotificationChannel mChannel = + new NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_DEFAULT); + + // Set the Notification Channel for the Notification Manager. + mNotificationManager.createNotificationChannel(mChannel); + } + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Log.i(TAG, "Service started"); + boolean startedFromNotification = intent.getBooleanExtra(EXTRA_STARTED_FROM_NOTIFICATION, + false); + + // We got here because the user decided to remove location updates from the notification. + if (startedFromNotification) { + removeLocationUpdates(); + stopSelf(); + } + // Tells the system to not try to recreate the service after it has been killed. + return START_NOT_STICKY; + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + mChangingConfiguration = true; + } + + @Override + public IBinder onBind(Intent intent) { + // Called when a client (MainActivity in case of this sample) comes to the foreground + // and binds with this service. The service should cease to be a foreground service + // when that happens. + Log.i(TAG, "in onBind()"); + stopForeground(true); + mChangingConfiguration = false; + return mBinder; + } + + @Override + public void onRebind(Intent intent) { + // Called when a client (MainActivity in case of this sample) returns to the foreground + // and binds once again with this service. The service should cease to be a foreground + // service when that happens. + Log.i(TAG, "in onRebind()"); + stopForeground(true); + mChangingConfiguration = false; + super.onRebind(intent); + } + + @Override + public boolean onUnbind(Intent intent) { + Log.i(TAG, "Last client unbound from service"); + + // Called when the last client (MainActivity in case of this sample) unbinds from this + // service. If this method is called due to a configuration change in MainActivity, we + // do nothing. Otherwise, we make this service a foreground service. + if (!mChangingConfiguration && Utils.requestingLocationUpdates(this)) { + Log.i(TAG, "Starting foreground service"); + /* + // TODO(developer). If targeting O, use the following code. + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.O) { + mNotificationManager.startServiceInForeground(new Intent(this, + LocationUpdatesService.class), NOTIFICATION_ID, getNotification()); + } else { + startForeground(NOTIFICATION_ID, getNotification()); + } + */ + startForeground(NOTIFICATION_ID, getNotification()); + } + return true; // Ensures onRebind() is called when a client re-binds. + } + + @Override + public void onDestroy() { + mServiceHandler.removeCallbacksAndMessages(null); + } + + /** + * Makes a request for location updates. Note that in this sample we merely log the + * {@link SecurityException}. + */ + public void requestLocationUpdates() { + Log.i(TAG, "Requesting location updates"); + Utils.setRequestingLocationUpdates(this, true); + startService(new Intent(getApplicationContext(), LocationUpdatesService.class)); + try { + mFusedLocationClient.requestLocationUpdates(mLocationRequest, + mLocationCallback, Looper.myLooper()); + } catch (SecurityException unlikely) { + Utils.setRequestingLocationUpdates(this, false); + Log.e(TAG, "Lost location permission. Could not request updates. " + unlikely); + } + } + + /** + * Removes location updates. Note that in this sample we merely log the + * {@link SecurityException}. + */ + public void removeLocationUpdates() { + Log.i(TAG, "Removing location updates"); + try { + mFusedLocationClient.removeLocationUpdates(mLocationCallback); + Utils.setRequestingLocationUpdates(this, false); + stopSelf(); + } catch (SecurityException unlikely) { + Utils.setRequestingLocationUpdates(this, true); + Log.e(TAG, "Lost location permission. Could not remove updates. " + unlikely); + } + } + + /** + * Returns the {@link NotificationCompat} used as part of the foreground service. + */ + private Notification getNotification() { + Intent intent = new Intent(this, LocationUpdatesService.class); + + CharSequence text = Utils.getLocationText(mLocation); + + // Extra to help us figure out if we arrived in onStartCommand via the notification or not. + intent.putExtra(EXTRA_STARTED_FROM_NOTIFICATION, true); + + // The PendingIntent that leads to a call to onStartCommand() in this service. + PendingIntent servicePendingIntent = PendingIntent.getService(this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT); + + // The PendingIntent to launch activity. + PendingIntent activityPendingIntent = PendingIntent.getActivity(this, 0, + new Intent(this, MainActivity.class), 0); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .addAction(R.drawable.blank_icon, "Open HA Client", + activityPendingIntent) + .addAction(R.drawable.blank_icon, "Stop", + servicePendingIntent) + .setContentText(text) + .setContentTitle(Utils.getLocationTitle(this)) + .setOngoing(true) + .setPriority(Notification.PRIORITY_HIGH) + .setSmallIcon(R.mipmap.ic_launcher) + .setTicker(text) + .setWhen(System.currentTimeMillis()); + + return builder.build(); + } + + private void getLastLocation() { + try { + mFusedLocationClient.getLastLocation() + .addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (task.isSuccessful() && task.getResult() != null) { + mLocation = task.getResult(); + } else { + Log.w(TAG, "Failed to get location."); + } + } + }); + } catch (SecurityException unlikely) { + Log.e(TAG, "Lost location permission." + unlikely); + } + } + + private void onNewLocation(Location location) { + Log.i(TAG, "New location: " + location); + + mLocation = location; + + // Notify anyone listening for broadcasts about the new location. + Intent intent = new Intent(ACTION_BROADCAST); + intent.putExtra(EXTRA_LOCATION, location); + LocalBroadcastManager.getInstance(getApplicationContext()).sendBroadcast(intent); + + // Update notification content if running as a foreground service. + if (serviceIsRunningInForeground(this)) { + mNotificationManager.notify(NOTIFICATION_ID, getNotification()); + } + } + + /** + * Sets the location request parameters. + */ + private void createLocationRequest() { + mLocationRequest = new LocationRequest(); + mLocationRequest.setInterval(UPDATE_INTERVAL_IN_MILLISECONDS); + mLocationRequest.setFastestInterval(FASTEST_UPDATE_INTERVAL_IN_MILLISECONDS); + mLocationRequest.setPriority(LocationRequest.PRIORITY_HIGH_ACCURACY); + } + + /** + * Class used for the client Binder. Since this service runs in the same process as its + * clients, we don't need to deal with IPC. + */ + public class LocalBinder extends Binder { + LocationUpdatesService getService() { + return LocationUpdatesService.this; + } + } + + /** + * Returns true if this is a foreground service. + * + * @param context The {@link Context}. + */ + public boolean serviceIsRunningInForeground(Context context) { + ActivityManager manager = (ActivityManager) context.getSystemService( + Context.ACTIVITY_SERVICE); + for (ActivityManager.RunningServiceInfo service : manager.getRunningServices( + Integer.MAX_VALUE)) { + if (getClass().getName().equals(service.service.getClassName())) { + if (service.foreground) { + return true; + } + } + } + return false; + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java index 189ed57..15b4d6e 100644 --- a/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/MainActivity.java @@ -1,15 +1,30 @@ package com.keyboardcrumbs.hassclient; import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + import io.flutter.embedding.android.FlutterActivity; import io.flutter.embedding.engine.FlutterEngine; import io.flutter.plugins.GeneratedPluginRegistrant; +import android.Manifest; +import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.ServiceConnection; import android.content.SharedPreferences; import android.content.SharedPreferences.Editor; +import android.content.pm.PackageManager; +import android.location.Location; import android.os.Bundle; +import android.os.IBinder; +import android.preference.PreferenceManager; +import android.util.Log; +import android.widget.Toast; import io.flutter.plugin.common.MethodCall; import io.flutter.plugin.common.MethodChannel; @@ -24,8 +39,39 @@ import com.google.firebase.messaging.FirebaseMessaging; public class MainActivity extends FlutterActivity { + private static final String TAG = MainActivity.class.getSimpleName(); + private static final String CHANNEL = "com.keyboardcrumbs.hassclient/native"; - + + // Used in checking for runtime permissions. + private static final int REQUEST_PERMISSIONS_REQUEST_CODE = 34; + + // The BroadcastReceiver used to listen from broadcasts from the service. + private MyReceiver myReceiver; + + // A reference to the service used to get location updates. + private LocationUpdatesService mService = null; + + // Tracks the bound state of the service. + private boolean mBound = false; + + // Monitors the state of the connection to the service. + private final ServiceConnection mServiceConnection = new ServiceConnection() { + + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + LocationUpdatesService.LocalBinder binder = (LocationUpdatesService.LocalBinder) service; + mService = binder.getService(); + mBound = true; + } + + @Override + public void onServiceDisconnected(ComponentName name) { + mService = null; + mBound = false; + } + }; + @Override public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { GeneratedPluginRegistrant.registerWith(flutterEngine); @@ -53,6 +99,16 @@ public class MainActivity extends FlutterActivity { } else { result.error("google_play_service_error", "Google Play Services unavailable", null); } + } else if (call.method.equals("startLocationService")) { + if (!checkPermissions()) { + requestPermissions(); + } else { + mService.requestLocationUpdates(); + } + result.success(""); + } else if (call.method.equals("stopLocationService")) { + mService.removeLocationUpdates(); + result.success(""); } } } @@ -66,6 +122,148 @@ public class MainActivity extends FlutterActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + myReceiver = new MyReceiver(); + // Check that the user hasn't revoked permissions by going to Settings. + if (Utils.requestingLocationUpdates(this)) { + if (!checkPermissions()) { + requestPermissions(); + } + } + } + + @Override + protected void onStart() { + super.onStart(); + // Bind to the service. If the service is in foreground mode, this signals to the service + // that since this activity is in the foreground, the service can exit foreground mode. + bindService(new Intent(this, LocationUpdatesService.class), mServiceConnection, + Context.BIND_AUTO_CREATE); + } + + @Override + protected void onResume() { + super.onResume(); + LocalBroadcastManager.getInstance(this).registerReceiver(myReceiver, + new IntentFilter(LocationUpdatesService.ACTION_BROADCAST)); + } + + @Override + protected void onPause() { + LocalBroadcastManager.getInstance(this).unregisterReceiver(myReceiver); + super.onPause(); + } + + @Override + protected void onStop() { + if (mBound) { + // Unbind from the service. This signals to the service that this activity is no longer + // in the foreground, and the service can respond by promoting itself to a foreground + // service. + unbindService(mServiceConnection); + mBound = false; + } + super.onStop(); + } + + /** + * Returns the current state of the permissions needed. + */ + private boolean checkPermissions() { + return PackageManager.PERMISSION_GRANTED == ActivityCompat.checkSelfPermission(this, + Manifest.permission.ACCESS_FINE_LOCATION); + } + + private void requestPermissions() { + boolean shouldProvideRationale = + ActivityCompat.shouldShowRequestPermissionRationale(this, + Manifest.permission.ACCESS_FINE_LOCATION); + + // Provide an additional rationale to the user. This would happen if the user denied the + // request previously, but didn't check the "Don't ask again" checkbox. + if (shouldProvideRationale) { + Log.i(TAG, "Displaying permission rationale to provide additional context."); + /* + Snackbar.make( + findViewById(R.id.activity_main), + R.string.permission_rationale, + Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.ok, new View.OnClickListener() { + @Override + public void onClick(View view) { + // Request permission + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, + REQUEST_PERMISSIONS_REQUEST_CODE); + } + }) + .show(); + */ + } else { + Log.i(TAG, "Requesting permission"); + // Request permission. It's possible this can be auto answered if device policy + // sets the permission in a given state or the user denied the permission + // previously and checked "Never ask again". + ActivityCompat.requestPermissions(MainActivity.this, + new String[]{Manifest.permission.ACCESS_FINE_LOCATION}, + REQUEST_PERMISSIONS_REQUEST_CODE); + } + } + + /** + * Callback received when a permissions request has been completed. + */ + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, + @NonNull int[] grantResults) { + if (requestCode == REQUEST_PERMISSIONS_REQUEST_CODE) { + if (grantResults.length <= 0) { + // If user interaction was interrupted, the permission request is cancelled and you + // receive empty arrays. + Log.i(TAG, "User interaction was cancelled."); + } else if (grantResults[0] == PackageManager.PERMISSION_GRANTED) { + // Permission was granted. + mService.requestLocationUpdates(); + } else { + // Permission denied. + /* + setButtonsState(false); + Snackbar.make( + findViewById(R.id.activity_main), + R.string.permission_denied_explanation, + Snackbar.LENGTH_INDEFINITE) + .setAction(R.string.settings, new View.OnClickListener() { + @Override + public void onClick(View view) { + // Build intent that displays the App settings screen. + Intent intent = new Intent(); + intent.setAction( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", + BuildConfig.APPLICATION_ID, null); + intent.setData(uri); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + } + }) + .show(); + + */ + } + } + } + + /** + * Receiver for broadcasts sent by {@link LocationUpdatesService}. + */ + private class MyReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + Location location = intent.getParcelableExtra(LocationUpdatesService.EXTRA_LOCATION); + if (location != null) { + Toast.makeText(MainActivity.this, Utils.getLocationText(location), + Toast.LENGTH_SHORT).show(); + } + } } } diff --git a/android/app/src/main/java/com/keyboardcrumbs/hassclient/Utils.java b/android/app/src/main/java/com/keyboardcrumbs/hassclient/Utils.java new file mode 100644 index 0000000..decc132 --- /dev/null +++ b/android/app/src/main/java/com/keyboardcrumbs/hassclient/Utils.java @@ -0,0 +1,46 @@ +package com.keyboardcrumbs.hassclient; + +import android.content.Context; +import android.location.Location; +import android.preference.PreferenceManager; + +import java.text.DateFormat; +import java.util.Date; + +class Utils { + + static final String KEY_REQUESTING_LOCATION_UPDATES = "flutter.foreground-location-service"; + + /** + * Returns true if requesting location updates, otherwise returns false. + * + * @param context The {@link Context}. + */ + static boolean requestingLocationUpdates(Context context) { + return context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE).getBoolean(KEY_REQUESTING_LOCATION_UPDATES, false); + } + + /** + * Stores the location updates state in SharedPreferences. + * @param requestingLocationUpdates The location updates state. + */ + static void setRequestingLocationUpdates(Context context, boolean requestingLocationUpdates) { + context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_REQUESTING_LOCATION_UPDATES, requestingLocationUpdates) + .apply(); + } + + /** + * Returns the {@code location} object as a human readable string. + * @param location The {@link Location}. + */ + static String getLocationText(Location location) { + return location == null ? "Unknown location" : + "(" + location.getLatitude() + ", " + location.getLongitude() + ")"; + } + + static String getLocationTitle(Context context) { + return "Location updated: " + DateFormat.getDateTimeInstance().format(new Date()); + } +} diff --git a/lib/pages/settings/integration_settings.part.dart b/lib/pages/settings/integration_settings.part.dart index e45ae0d..94977a1 100644 --- a/lib/pages/settings/integration_settings.part.dart +++ b/lib/pages/settings/integration_settings.part.dart @@ -11,8 +11,11 @@ class IntegrationSettingsPage extends StatefulWidget { class _IntegrationSettingsPageState extends State { + static const platform = const MethodChannel('com.keyboardcrumbs.hassclient/native'); + int _locationInterval = AppSettings().defaultLocationUpdateIntervalMinutes; bool _locationTrackingEnabled = false; + bool _foregroundLocationTrackingEnabled = false; bool _wait = false; @override @@ -28,6 +31,7 @@ class _IntegrationSettingsPageState extends State { SharedPreferences.getInstance().then((prefs) { setState(() { _locationTrackingEnabled = prefs.getBool("location-enabled") ?? false; + _foregroundLocationTrackingEnabled = prefs.getBool("foreground-location-service") ?? false; _locationInterval = prefs.getInt("location-interval") ?? AppSettings().defaultLocationUpdateIntervalMinutes; if (_locationInterval % 5 != 0) { @@ -63,6 +67,17 @@ class _IntegrationSettingsPageState extends State { }); } + _switchForegroundLocationTrackingState(bool state) async { + if (state) { + await platform.invokeMethod('startLocationService'); + } else { + await platform.invokeMethod('stopLocationService'); + } + setState(() { + _wait = false; + }); + } + @override Widget build(BuildContext context) { return ListView( @@ -84,7 +99,7 @@ class _IntegrationSettingsPageState extends State { Container(height: Sizes.rowPadding,), Row( children: [ - Text("Enable device location tracking"), + Text("Enable location tracking"), Switch( value: _locationTrackingEnabled, onChanged: _wait ? null : (value) { @@ -97,7 +112,23 @@ class _IntegrationSettingsPageState extends State { ), ], ), - Container(height: Sizes.rowPadding,), + Container(height: Sizes.rowPadding), + Row( + children: [ + Text("Foreground tracking"), + Switch( + value: _foregroundLocationTrackingEnabled, + onChanged: _wait ? null : (value) { + setState(() { + _foregroundLocationTrackingEnabled = value; + _wait = true; + }); + _switchForegroundLocationTrackingState(value); + }, + ), + ], + ), + Container(height: Sizes.rowPadding), Text("Location update interval in minutes:"), Row( mainAxisAlignment: MainAxisAlignment.center,