From 9e71e3571fb34a591082c8a767db0acc811419e4 Mon Sep 17 00:00:00 2001 From: ostrya Date: Wed, 30 Oct 2019 10:23:02 +0100 Subject: [PATCH] migrate to alarm manager To make sure the messages keep getting sent while the app is in doze, remove foreground service and instead use alarm manager to explicitly schedule publishing messages. Also, request disabling of battery optimization in Android 6+. Due to Android policy, less than 15 minutes schedule is not allowed, so adapted allowed schedule range. --- README.md | 1 + app/build.gradle | 16 +- app/src/main/AndroidManifest.xml | 5 +- .../ostrya/presencepublisher/Application.java | 26 ++ .../presencepublisher/ForegroundService.java | 264 ------------------ .../presencepublisher/MainActivity.java | 117 +++++++- .../presencepublisher/mqtt/MqttService.java | 2 +- .../presencepublisher/mqtt/Publisher.java | 113 ++++++++ .../receiver/AlarmReceiver.java | 16 +- .../receiver/SystemBroadcastReceiver.java | 21 +- .../ui/LogRecyclerViewAdapter.java | 1 + .../ui/notification/NotificationFactory.java | 58 ++-- .../preference/MessageSchedulePreference.java | 4 +- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 15 files changed, 325 insertions(+), 323 deletions(-) delete mode 100644 app/src/main/java/org/ostrya/presencepublisher/ForegroundService.java create mode 100644 app/src/main/java/org/ostrya/presencepublisher/mqtt/Publisher.java diff --git a/README.md b/README.md index c362bec..c1afd8a 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ certificate via: * INTERNET: only necessary if your MQTT server is not running locally * FOREGROUND_SERVICE: necessary to send notifications * RECEIVE_BOOT_COMPLETED: necessary to start service on start-up +* REQUEST_IGNORE_BATTERY_OPTIMIZATIONS: on Android 6+, necessary to request disabling battery optimization * WRITE_EXTERNAL_STORAGE: only necessary if you want to export log files in Android 4.0 - 4.3 ## Future ideas diff --git a/app/build.gradle b/app/build.gradle index 8f5aac0..b695d04 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,5 @@ +import org.apache.tools.ant.taskdefs.condition.Os + plugins { id 'com.jaredsburrows.license' version '0.8.42' id 'pl.allegro.tech.build.axion-release' version '1.10.2' @@ -63,13 +65,25 @@ android { } afterEvaluate { - preBuild.dependsOn(licenseDebugReport) + preBuild.dependsOn(generateLicenseFile) +} + +// work around empty license file bug +// see https://github.com/jaredsburrows/gradle-license-plugin/issues/38 +task generateLicenseFile(type: Exec) { + workingDir project.rootDir + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + commandLine 'gradlew.bat', 'app:licenseReleaseReport' + } else { + commandLine './gradlew', 'app:licenseReleaseReport' + } } dependencies { implementation 'androidx.appcompat:appcompat:1.1.0' implementation 'androidx.preference:preference:1.1.0' implementation 'androidx.security:security-crypto:1.0.0-alpha02' + implementation 'androidx.work:work-runtime:2.2.0' implementation('com.hypertrack:hyperlog:0.0.10') { exclude group: 'com.android.support' exclude group: 'com.android.volley' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3720887..1db802e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ + @@ -32,10 +33,6 @@ - - diff --git a/app/src/main/java/org/ostrya/presencepublisher/Application.java b/app/src/main/java/org/ostrya/presencepublisher/Application.java index f058dbb..1cd2bdc 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/Application.java +++ b/app/src/main/java/org/ostrya/presencepublisher/Application.java @@ -1,13 +1,30 @@ package org.ostrya.presencepublisher; +import android.content.IntentFilter; +import android.net.ConnectivityManager; +import android.os.Build; import android.util.Log; import com.hypertrack.hyperlog.HyperLog; import org.ostrya.presencepublisher.log.CustomLogFormat; +import org.ostrya.presencepublisher.receiver.SystemBroadcastReceiver; +import org.ostrya.presencepublisher.ui.notification.NotificationFactory; public class Application extends android.app.Application { + public static final int PERMISSION_REQUEST_CODE = 1; + public static final int LOCATION_REQUEST_CODE = 2; + public static final int BATTERY_OPTIMIZATION_REQUEST_CODE = 3; + public static final int ALARM_REQUEST_CODE = 4; + public static final int MAIN_ACTIVITY_REQUEST_CODE = 5; + @Override public void onCreate() { super.onCreate(); + initLogger(); + initNetworkReceiver(); + NotificationFactory.createNotificationChannel(this); + } + + private void initLogger() { HyperLog.initialize(this, new CustomLogFormat(this)); if (BuildConfig.DEBUG) { HyperLog.setLogLevel(Log.VERBOSE); @@ -15,4 +32,13 @@ public void onCreate() { HyperLog.setLogLevel(Log.INFO); } } + + @SuppressWarnings("deprecation") + private void initNetworkReceiver() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + SystemBroadcastReceiver receiver = new SystemBroadcastReceiver(); + IntentFilter filter = new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION); + registerReceiver(receiver, filter); + } + } } diff --git a/app/src/main/java/org/ostrya/presencepublisher/ForegroundService.java b/app/src/main/java/org/ostrya/presencepublisher/ForegroundService.java deleted file mode 100644 index 870e8c8..0000000 --- a/app/src/main/java/org/ostrya/presencepublisher/ForegroundService.java +++ /dev/null @@ -1,264 +0,0 @@ -package org.ostrya.presencepublisher; - -import android.app.AlarmManager; -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.SharedPreferences; -import android.content.SharedPreferences.OnSharedPreferenceChangeListener; -import android.net.ConnectivityManager; -import android.net.Network; -import android.os.Build; -import android.os.IBinder; -import androidx.core.app.NotificationManagerCompat; -import androidx.preference.PreferenceManager; -import com.hypertrack.hyperlog.HyperLog; -import org.ostrya.presencepublisher.message.Message; -import org.ostrya.presencepublisher.message.battery.BatteryMessageProvider; -import org.ostrya.presencepublisher.message.wifi.WifiMessageProvider; -import org.ostrya.presencepublisher.mqtt.MqttService; -import org.ostrya.presencepublisher.receiver.AlarmReceiver; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.List; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicBoolean; - -import static org.ostrya.presencepublisher.ui.ScheduleFragment.SSID; -import static org.ostrya.presencepublisher.ui.notification.NotificationFactory.getServiceNotification; -import static org.ostrya.presencepublisher.ui.notification.NotificationFactory.updateServiceNotification; -import static org.ostrya.presencepublisher.ui.preference.AutostartPreference.AUTOSTART; -import static org.ostrya.presencepublisher.ui.preference.BatteryTopicPreference.BATTERY_TOPIC; -import static org.ostrya.presencepublisher.ui.preference.ClientCertificatePreference.CLIENT_CERTIFICATE; -import static org.ostrya.presencepublisher.ui.preference.HostPreference.HOST; -import static org.ostrya.presencepublisher.ui.preference.LastSuccessTimestampPreference.LAST_SUCCESS; -import static org.ostrya.presencepublisher.ui.preference.MessageSchedulePreference.MESSAGE_SCHEDULE; -import static org.ostrya.presencepublisher.ui.preference.NextScheduleTimestampPreference.NEXT_SCHEDULE; -import static org.ostrya.presencepublisher.ui.preference.OfflineContentPreference.OFFLINE_CONTENT; -import static org.ostrya.presencepublisher.ui.preference.PasswordPreference.PASSWORD; -import static org.ostrya.presencepublisher.ui.preference.PortPreference.PORT; -import static org.ostrya.presencepublisher.ui.preference.PresenceTopicPreference.PRESENCE_TOPIC; -import static org.ostrya.presencepublisher.ui.preference.SendBatteryMessagePreference.SEND_BATTERY_MESSAGE; -import static org.ostrya.presencepublisher.ui.preference.SendOfflineMessagePreference.SEND_OFFLINE_MESSAGE; -import static org.ostrya.presencepublisher.ui.preference.SendViaMobileNetworkPreference.SEND_VIA_MOBILE_NETWORK; -import static org.ostrya.presencepublisher.ui.preference.SsidListPreference.SSID_LIST; -import static org.ostrya.presencepublisher.ui.preference.UseTlsPreference.USE_TLS; -import static org.ostrya.presencepublisher.ui.preference.UsernamePreference.USERNAME; -import static org.ostrya.presencepublisher.ui.preference.WifiContentPreference.WIFI_CONTENT_PREFIX; - -public class ForegroundService extends Service { - public static final String ALARM_ACTION = "org.ostrya.presencepublisher.ALARM_ACTION"; - - private static final String TAG = "ForegroundService"; - - private static final String CHANNEL_ID = "org.ostrya.presencepublisher"; - private static final String CHANNEL_NAME = "Presence Publisher"; - private static final int NOTIFICATION_ID = 1; - - private final ExecutorService executorService = Executors.newSingleThreadExecutor(); - private final AtomicBoolean currentlyRunning = new AtomicBoolean(); - private PendingIntent pendingIntent; - private MqttService mqttService; - private ConnectivityManager connectivityManager; - private AlarmManager alarmManager; - private long lastSuccess; - private SharedPreferences sharedPreferences; - private long nextSchedule; - private WifiMessageProvider wifiMessageProvider; - private BatteryMessageProvider batteryMessageProvider; - private final OnSharedPreferenceChangeListener sharedPreferenceListener = this::onSharedPreferenceChanged; - - public static void startService(Context context) { - startService(context, new Intent()); - } - - public static void startService(Context context, Intent intent) { - HyperLog.d(TAG, "Starting service ..."); - Context applicationContext = context.getApplicationContext(); - intent.setClass(applicationContext, ForegroundService.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - applicationContext.startForegroundService(intent); - } else { - applicationContext.startService(intent); - } - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - super.onStartCommand(intent, flags, startId); - HyperLog.i(TAG, "Received start intent " + (intent == null ? "null" : intent.getAction())); - start(); - return START_STICKY; - } - - private void showNotificationAndStartInForeground() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationManager notificationManager = getApplicationContext().getSystemService(NotificationManager.class); - if (notificationManager != null) { - HyperLog.d(TAG, "Setting notification"); - notificationManager - .createNotificationChannel(new NotificationChannel(CHANNEL_ID, CHANNEL_NAME, NotificationManager.IMPORTANCE_DEFAULT)); - } - } - - Notification notification = getServiceNotification(getApplicationContext(), CHANNEL_ID); - startForeground(NOTIFICATION_ID, notification); - } - - @Override - public void onCreate() { - HyperLog.i(TAG, "Starting service"); - super.onCreate(); - showNotificationAndStartInForeground(); - mqttService = new MqttService(this); - connectivityManager = (ConnectivityManager) getApplicationContext().getSystemService(CONNECTIVITY_SERVICE); - alarmManager = (AlarmManager) getApplicationContext().getSystemService(ALARM_SERVICE); - Intent intent = new Intent(ALARM_ACTION); - intent.setClass(getApplicationContext(), AlarmReceiver.class); - pendingIntent = PendingIntent.getBroadcast(getApplicationContext(), 0, intent, 0); - sharedPreferences = PreferenceManager.getDefaultSharedPreferences(getApplicationContext()); - lastSuccess = sharedPreferences.getLong(LAST_SUCCESS, 0L); - nextSchedule = sharedPreferences.getLong(NEXT_SCHEDULE, 0L); - wifiMessageProvider = new WifiMessageProvider(this); - batteryMessageProvider = new BatteryMessageProvider(this); - registerPreferenceCallback(); - migrateOldPreference(); - registerNetworkCallback(); - registerWatchDog(); - HyperLog.d(TAG, "Starting service finished"); - } - - @SuppressWarnings("deprecation") - private void migrateOldPreference() { - if (sharedPreferences.contains(SSID) && !sharedPreferences.contains(SSID_LIST)) { - HyperLog.d(TAG, "Migrating wifi network to new parameter"); - String ssid = sharedPreferences.getString(SSID, ""); - if (!"".equals(ssid)) { - sharedPreferences.edit().putStringSet(SSID_LIST, Collections.singleton(ssid)).apply(); - } - } - } - - private void registerNetworkCallback() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - connectivityManager.registerDefaultNetworkCallback(new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(final Network network) { - HyperLog.i(TAG, "Network available"); - super.onAvailable(network); - start(); - } - }); - } - } - - private void registerWatchDog() { - // if for some reason individual scheduling fails, this will make sure it is resumed at least once per hour - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis() + AlarmManager.INTERVAL_HOUR, - AlarmManager.INTERVAL_HOUR, pendingIntent); - } - - private void registerPreferenceCallback() { - sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceListener); - } - - private void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - switch (key) { - case HOST: - case PORT: - case USE_TLS: - case CLIENT_CERTIFICATE: - case PRESENCE_TOPIC: - case MESSAGE_SCHEDULE: - case USERNAME: - case PASSWORD: - case SSID_LIST: - case SEND_OFFLINE_MESSAGE: - case SEND_VIA_MOBILE_NETWORK: - case SEND_BATTERY_MESSAGE: - case BATTERY_TOPIC: - case OFFLINE_CONTENT: - HyperLog.i(TAG, "Changed parameter " + key); - start(); - break; - case AUTOSTART: - case LAST_SUCCESS: - case NEXT_SCHEDULE: - break; - default: - if (key.startsWith(WIFI_CONTENT_PREFIX)) { - HyperLog.i(TAG, "Changed parameter " + key); - start(); - } else { - HyperLog.v(TAG, "Ignoring unexpected value " + key); - } - } - } - - private void start() { - if (!currentlyRunning.compareAndSet(false, true)) { - HyperLog.d(TAG, "Skip message scheduling as already running"); - return; - } - try { - try { - List messages = getMessagesToSend(); - if (!messages.isEmpty()) { - HyperLog.d(TAG, "Sending messages in background"); - executorService.submit(() -> doSend(messages)); - } - } catch (RuntimeException e) { - HyperLog.w(TAG, "Error while getting messages to send", e); - } - int ping = sharedPreferences.getInt(MESSAGE_SCHEDULE, 15); - nextSchedule = System.currentTimeMillis() + ping * 60_000L; - HyperLog.i(TAG, "Re-scheduling for " + new Date(nextSchedule)); - sharedPreferences.edit().putLong(NEXT_SCHEDULE, nextSchedule).apply(); - updateNotification(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, nextSchedule, pendingIntent); - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { - alarmManager.setExact(AlarmManager.RTC_WAKEUP, nextSchedule, pendingIntent); - } else { - alarmManager.set(AlarmManager.RTC_WAKEUP, nextSchedule, pendingIntent); - } - } finally { - currentlyRunning.set(false); - } - } - - private void updateNotification() { - NotificationManagerCompat.from(this) - .notify(NOTIFICATION_ID, updateServiceNotification(getApplicationContext(), lastSuccess, nextSchedule, CHANNEL_ID)); - } - - @Override - public IBinder onBind(final Intent intent) { - return null; - } - - private void doSend(List messages) { - try { - mqttService.sendMessages(messages); - lastSuccess = System.currentTimeMillis(); - sharedPreferences.edit().putLong(LAST_SUCCESS, lastSuccess).apply(); - updateNotification(); - } catch (Exception e) { - HyperLog.w(TAG, "Error while sending messages", e); - } - } - - private List getMessagesToSend() { - List result = new ArrayList<>(); - result.addAll(wifiMessageProvider.getMessages()); - result.addAll(batteryMessageProvider.getMessages()); - return Collections.unmodifiableList(result); - } -} diff --git a/app/src/main/java/org/ostrya/presencepublisher/MainActivity.java b/app/src/main/java/org/ostrya/presencepublisher/MainActivity.java index 7932a08..6a5e61c 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/MainActivity.java +++ b/app/src/main/java/org/ostrya/presencepublisher/MainActivity.java @@ -1,32 +1,57 @@ package org.ostrya.presencepublisher; import android.Manifest; +import android.annotation.SuppressLint; import android.content.Intent; +import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.location.LocationManager; +import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.PowerManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import androidx.preference.PreferenceManager; import androidx.viewpager.widget.ViewPager; import com.hypertrack.hyperlog.HyperLog; +import org.ostrya.presencepublisher.mqtt.Publisher; import org.ostrya.presencepublisher.ui.MainPagerAdapter; import org.ostrya.presencepublisher.ui.dialog.ConfirmationDialogFragment; import static android.provider.Settings.ACTION_LOCATION_SOURCE_SETTINGS; +import static android.provider.Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS; +import static org.ostrya.presencepublisher.Application.*; +import static org.ostrya.presencepublisher.ui.preference.AutostartPreference.AUTOSTART; +import static org.ostrya.presencepublisher.ui.preference.BatteryTopicPreference.BATTERY_TOPIC; +import static org.ostrya.presencepublisher.ui.preference.ClientCertificatePreference.CLIENT_CERTIFICATE; +import static org.ostrya.presencepublisher.ui.preference.HostPreference.HOST; +import static org.ostrya.presencepublisher.ui.preference.LastSuccessTimestampPreference.LAST_SUCCESS; +import static org.ostrya.presencepublisher.ui.preference.MessageSchedulePreference.MESSAGE_SCHEDULE; +import static org.ostrya.presencepublisher.ui.preference.NextScheduleTimestampPreference.NEXT_SCHEDULE; +import static org.ostrya.presencepublisher.ui.preference.OfflineContentPreference.OFFLINE_CONTENT; +import static org.ostrya.presencepublisher.ui.preference.PasswordPreference.PASSWORD; +import static org.ostrya.presencepublisher.ui.preference.PortPreference.PORT; +import static org.ostrya.presencepublisher.ui.preference.PresenceTopicPreference.PRESENCE_TOPIC; +import static org.ostrya.presencepublisher.ui.preference.SendBatteryMessagePreference.SEND_BATTERY_MESSAGE; +import static org.ostrya.presencepublisher.ui.preference.SendOfflineMessagePreference.SEND_OFFLINE_MESSAGE; +import static org.ostrya.presencepublisher.ui.preference.SendViaMobileNetworkPreference.SEND_VIA_MOBILE_NETWORK; +import static org.ostrya.presencepublisher.ui.preference.SsidListPreference.SSID_LIST; +import static org.ostrya.presencepublisher.ui.preference.UseTlsPreference.USE_TLS; +import static org.ostrya.presencepublisher.ui.preference.UsernamePreference.USERNAME; +import static org.ostrya.presencepublisher.ui.preference.WifiContentPreference.WIFI_CONTENT_PREFIX; public class MainActivity extends FragmentActivity { private static final String TAG = "MainActivity"; - private static final int PERMISSION_REQUEST_CODE = 1; - private static final int LOCATION_REQUEST_CODE = 2; - + private final SharedPreferences.OnSharedPreferenceChangeListener sharedPreferenceListener = this::onSharedPreferenceChanged; private MainPagerAdapter mainPagerAdapter; private ViewPager viewPager; + private SharedPreferences sharedPreferences; @Override public void onCreate(Bundle savedInstanceState) { @@ -38,14 +63,25 @@ public void onCreate(Bundle savedInstanceState) { viewPager = findViewById(R.id.pager); viewPager.setAdapter(mainPagerAdapter); - checkLocationPermissionAndAccessAndStartService(); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceListener); + + checkLocationPermissionAndAccessAndBatteryOptimizationAndStartWorker(); + HyperLog.d(TAG, "Creating activity finished"); } @Override protected void onResume() { super.onResume(); - ForegroundService.startService(this); + sharedPreferences.registerOnSharedPreferenceChangeListener(sharedPreferenceListener); + new Publisher(this).scheduleNow(); + } + + @Override + protected void onPause() { + super.onPause(); + sharedPreferences.unregisterOnSharedPreferenceChangeListener(sharedPreferenceListener); } @Override @@ -53,7 +89,7 @@ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permis if (requestCode == PERMISSION_REQUEST_CODE && grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { HyperLog.i(TAG, "Successfully granted location permission"); - checkLocationAccessAndStartService(); + checkLocationAccessAndBatteryOptimizationAndStartWorker(); } } @@ -64,11 +100,47 @@ protected void onActivityResult(int requestCode, int resultCode, @Nullable Inten // For some reason, the activity returns RESULT_CANCELED even when the service is enabled, so we don't // know if it was actually enabled or not. For now, we don't check again and just start the service. HyperLog.d(TAG, "Returning from location service with result " + resultCode + ", assuming it is running ..."); - ForegroundService.startService(this); + checkBatteryOptimizationAndStartWorker(); + } else if (requestCode == BATTERY_OPTIMIZATION_REQUEST_CODE) { + HyperLog.d(TAG, "Returning from battery optimization with result " + resultCode + ", assuming it disabled ..."); + new Publisher(this).scheduleNow(); + } + } + + private void onSharedPreferenceChanged(SharedPreferences preferences, String key) { + switch (key) { + case BATTERY_TOPIC: + case CLIENT_CERTIFICATE: + case HOST: + case MESSAGE_SCHEDULE: + case OFFLINE_CONTENT: + case PASSWORD: + case PORT: + case PRESENCE_TOPIC: + case SEND_BATTERY_MESSAGE: + case SEND_OFFLINE_MESSAGE: + case SEND_VIA_MOBILE_NETWORK: + case SSID_LIST: + case USERNAME: + case USE_TLS: + HyperLog.i(TAG, "Changed parameter " + key); + new Publisher(this).scheduleNow(); + break; + case AUTOSTART: + case LAST_SUCCESS: + case NEXT_SCHEDULE: + break; + default: + if (key.startsWith(WIFI_CONTENT_PREFIX)) { + HyperLog.i(TAG, "Changed parameter " + key); + new Publisher(this).scheduleNow(); + } else { + HyperLog.v(TAG, "Ignoring unexpected value " + key); + } } } - private void checkLocationPermissionAndAccessAndStartService() { + private void checkLocationPermissionAndAccessAndBatteryOptimizationAndStartWorker() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && ContextCompat.checkSelfPermission(getApplicationContext(), Manifest.permission.ACCESS_COARSE_LOCATION) != PackageManager.PERMISSION_GRANTED) { @@ -82,11 +154,11 @@ private void checkLocationPermissionAndAccessAndStartService() { }, R.string.permission_dialog_title, R.string.permission_dialog_message); fragment.show(fm, null); } else { - checkLocationAccessAndStartService(); + checkLocationAccessAndBatteryOptimizationAndStartWorker(); } } - private void checkLocationAccessAndStartService() { + private void checkLocationAccessAndBatteryOptimizationAndStartWorker() { LocationManager locationManager = (LocationManager) getSystemService(LOCATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && (locationManager == null || !(locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER) || locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)))) { @@ -100,7 +172,30 @@ private void checkLocationAccessAndStartService() { }, R.string.location_dialog_title, R.string.location_dialog_message); fragment.show(fm, null); } else { - ForegroundService.startService(this); + checkBatteryOptimizationAndStartWorker(); + } + } + + private void checkBatteryOptimizationAndStartWorker() { + PowerManager powerManager = (PowerManager) getSystemService(POWER_SERVICE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && powerManager != null + && !powerManager.isIgnoringBatteryOptimizations(getPackageName())) { + HyperLog.d(TAG, "Battery optimization not yet disabled, asking user ..."); + FragmentManager fm = getSupportFragmentManager(); + + // this app should fall under "task automation app" in + // https://developer.android.com/training/monitoring-device-state/doze-standby.html#whitelisting-cases + @SuppressLint("BatteryLife") + ConfirmationDialogFragment fragment = ConfirmationDialogFragment.getInstance(ok -> { + if (ok) { + Uri packageUri = Uri.fromParts("package", getPackageName(), null); + startActivityForResult( + new Intent(ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, packageUri), BATTERY_OPTIMIZATION_REQUEST_CODE); + } + }, R.string.battery_optimization_dialog_title, R.string.battery_optimization_dialog_message); + fragment.show(fm, null); + } else { + new Publisher(this).scheduleNow(); } } } diff --git a/app/src/main/java/org/ostrya/presencepublisher/mqtt/MqttService.java b/app/src/main/java/org/ostrya/presencepublisher/mqtt/MqttService.java index 9a8ce62..0bb249c 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/mqtt/MqttService.java +++ b/app/src/main/java/org/ostrya/presencepublisher/mqtt/MqttService.java @@ -37,7 +37,7 @@ public MqttService(Context context) { } public void sendMessages(List messages) throws MqttException { - HyperLog.i(TAG, "Sending messages to server"); + HyperLog.i(TAG, "Sending " + messages.size() + " messages to server"); boolean tls = sharedPreferences.getBoolean(USE_TLS, false); String clientCertAlias = sharedPreferences.getString(CLIENT_CERTIFICATE, null); String login = sharedPreferences.getString(USERNAME, ""); diff --git a/app/src/main/java/org/ostrya/presencepublisher/mqtt/Publisher.java b/app/src/main/java/org/ostrya/presencepublisher/mqtt/Publisher.java new file mode 100644 index 0000000..0b7e100 --- /dev/null +++ b/app/src/main/java/org/ostrya/presencepublisher/mqtt/Publisher.java @@ -0,0 +1,113 @@ +package org.ostrya.presencepublisher.mqtt; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import androidx.core.app.NotificationManagerCompat; +import androidx.preference.PreferenceManager; +import com.hypertrack.hyperlog.HyperLog; +import org.ostrya.presencepublisher.message.Message; +import org.ostrya.presencepublisher.message.battery.BatteryMessageProvider; +import org.ostrya.presencepublisher.message.wifi.WifiMessageProvider; +import org.ostrya.presencepublisher.receiver.AlarmReceiver; +import org.ostrya.presencepublisher.ui.notification.NotificationFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +import static android.app.AlarmManager.RTC_WAKEUP; +import static org.ostrya.presencepublisher.Application.ALARM_REQUEST_CODE; +import static org.ostrya.presencepublisher.receiver.AlarmReceiver.ALARM_ACTION; +import static org.ostrya.presencepublisher.ui.preference.LastSuccessTimestampPreference.LAST_SUCCESS; +import static org.ostrya.presencepublisher.ui.preference.MessageSchedulePreference.MESSAGE_SCHEDULE; +import static org.ostrya.presencepublisher.ui.preference.NextScheduleTimestampPreference.NEXT_SCHEDULE; + +public class Publisher { + private static final String TAG = "Publisher"; + + private static final int NOTIFICATION_ID = 1; + + private final Context applicationContext; + private final SharedPreferences sharedPreferences; + private final WifiMessageProvider wifiMessageProvider; + private final BatteryMessageProvider batteryMessageProvider; + private final MqttService mqttService; + private final PendingIntent scheduledIntent; + private long lastSuccess; + + public Publisher(Context context) { + applicationContext = context.getApplicationContext(); + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(applicationContext); + wifiMessageProvider = new WifiMessageProvider(applicationContext); + batteryMessageProvider = new BatteryMessageProvider(applicationContext); + mqttService = new MqttService(applicationContext); + Intent intent = new Intent(applicationContext, AlarmReceiver.class); + intent.setAction(ALARM_ACTION); + scheduledIntent = PendingIntent.getBroadcast(applicationContext, ALARM_REQUEST_CODE, intent, PendingIntent.FLAG_CANCEL_CURRENT); + lastSuccess = sharedPreferences.getLong(LAST_SUCCESS, 0); + } + + public void publish() { + try { + List messages = getMessagesToSend(); + if (!messages.isEmpty()) { + doSend(messages); + } + } catch (RuntimeException e) { + HyperLog.w(TAG, "Error while getting messages to send", e); + } finally { + scheduleNext(); + } + } + + private void doSend(List messages) { + HyperLog.d(TAG, "Sending messages"); + try { + mqttService.sendMessages(messages); + lastSuccess = System.currentTimeMillis(); + sharedPreferences.edit().putLong(LAST_SUCCESS, lastSuccess).apply(); + } catch (Exception e) { + HyperLog.w(TAG, "Error while sending messages", e); + } + } + + public void scheduleNow() { + scheduleFor(System.currentTimeMillis()); + } + + private void scheduleNext() { + int messageScheduleInMinutes = sharedPreferences.getInt(MESSAGE_SCHEDULE, 15); + scheduleFor(System.currentTimeMillis() + messageScheduleInMinutes * 60_000L); + } + + private void scheduleFor(long nextSchedule) { + AlarmManager alarmManager = (AlarmManager) applicationContext.getSystemService(Context.ALARM_SERVICE); + if (alarmManager == null) { + HyperLog.e(TAG, "Unable to get alarm manager, cannot schedule!"); + return; + } + alarmManager.cancel(scheduledIntent); + HyperLog.i(TAG, "Next run at " + new Date(nextSchedule)); + sharedPreferences.edit().putLong(NEXT_SCHEDULE, nextSchedule).apply(); + NotificationManagerCompat.from(applicationContext) + .notify(NOTIFICATION_ID, NotificationFactory.getNotification(applicationContext, lastSuccess, nextSchedule)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmManager.setAndAllowWhileIdle(RTC_WAKEUP, nextSchedule, scheduledIntent); + } else { + alarmManager.set(RTC_WAKEUP, nextSchedule, scheduledIntent); + } + } + + private List getMessagesToSend() { + List result = new ArrayList<>(); + result.addAll(wifiMessageProvider.getMessages()); + result.addAll(batteryMessageProvider.getMessages()); + return Collections.unmodifiableList(result); + } + +} diff --git a/app/src/main/java/org/ostrya/presencepublisher/receiver/AlarmReceiver.java b/app/src/main/java/org/ostrya/presencepublisher/receiver/AlarmReceiver.java index b2bc6f1..4ee73fd 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/receiver/AlarmReceiver.java +++ b/app/src/main/java/org/ostrya/presencepublisher/receiver/AlarmReceiver.java @@ -4,17 +4,25 @@ import android.content.Context; import android.content.Intent; import com.hypertrack.hyperlog.HyperLog; -import org.ostrya.presencepublisher.ForegroundService; +import org.ostrya.presencepublisher.mqtt.Publisher; public class AlarmReceiver extends BroadcastReceiver { + public static final String ALARM_ACTION = "org.ostrya.presencepublisher.ALARM"; + private static final String TAG = "AlarmReceiver"; @Override - public void onReceive(final Context context, final Intent intent) { + public void onReceive(Context context, Intent intent) { String action = intent.getAction(); - if (ForegroundService.ALARM_ACTION.equals(action)) { + if (ALARM_ACTION.equals(action)) { HyperLog.i(TAG, "Alarm broadcast received"); - ForegroundService.startService(context, intent); + PendingResult pendingResult = goAsync(); + new Thread(() -> publish(context, pendingResult)).start(); } } + + private void publish(Context context, PendingResult pendingResult) { + new Publisher(context).publish(); + pendingResult.finish(); + } } diff --git a/app/src/main/java/org/ostrya/presencepublisher/receiver/SystemBroadcastReceiver.java b/app/src/main/java/org/ostrya/presencepublisher/receiver/SystemBroadcastReceiver.java index ef4e482..fa66e30 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/receiver/SystemBroadcastReceiver.java +++ b/app/src/main/java/org/ostrya/presencepublisher/receiver/SystemBroadcastReceiver.java @@ -5,11 +5,14 @@ import android.content.Intent; import android.content.SharedPreferences; import android.net.ConnectivityManager; +import android.net.NetworkInfo; import androidx.preference.PreferenceManager; import com.hypertrack.hyperlog.HyperLog; -import org.ostrya.presencepublisher.ForegroundService; +import org.ostrya.presencepublisher.mqtt.Publisher; import static org.ostrya.presencepublisher.ui.preference.AutostartPreference.AUTOSTART; +import static org.ostrya.presencepublisher.ui.preference.SendOfflineMessagePreference.SEND_OFFLINE_MESSAGE; +import static org.ostrya.presencepublisher.ui.preference.SendViaMobileNetworkPreference.SEND_VIA_MOBILE_NETWORK; public class SystemBroadcastReceiver extends BroadcastReceiver { private static final String TAG = "SystemBroadcastReceiver"; @@ -19,10 +22,18 @@ public class SystemBroadcastReceiver extends BroadcastReceiver { public void onReceive(final Context context, final Intent intent) { String action = intent.getAction(); SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); - if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action) - || (Intent.ACTION_BOOT_COMPLETED.equals(action) && sharedPreferences.getBoolean(AUTOSTART, false))) { - HyperLog.i(TAG, "Received intent " + action); - ForegroundService.startService(context, intent); + if (ConnectivityManager.CONNECTIVITY_ACTION.equals(action)) { + NetworkInfo networkInfo = intent.getParcelableExtra(ConnectivityManager.EXTRA_NETWORK_INFO); + boolean useMobile = sharedPreferences.getBoolean(SEND_OFFLINE_MESSAGE, false) + && sharedPreferences.getBoolean(SEND_VIA_MOBILE_NETWORK, false); + if (networkInfo.isConnected() && (networkInfo.getType() == ConnectivityManager.TYPE_WIFI || useMobile)) { + HyperLog.i(TAG, "Reacting to network change"); + new Publisher(context).scheduleNow(); + } + } else if (Intent.ACTION_BOOT_COMPLETED.equals(action) && sharedPreferences.getBoolean(AUTOSTART, false)) { + HyperLog.i(TAG, "Starting after boot"); + new Publisher(context).scheduleNow(); } } + } diff --git a/app/src/main/java/org/ostrya/presencepublisher/ui/LogRecyclerViewAdapter.java b/app/src/main/java/org/ostrya/presencepublisher/ui/LogRecyclerViewAdapter.java index 68684ac..0a93742 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/ui/LogRecyclerViewAdapter.java +++ b/app/src/main/java/org/ostrya/presencepublisher/ui/LogRecyclerViewAdapter.java @@ -54,6 +54,7 @@ public class ViewHolder extends RecyclerView.ViewHolder { mContentView = view.findViewById(R.id.content); } + @NonNull @Override public String toString() { return super.toString() + " '" + mContentView.getText() + "'"; diff --git a/app/src/main/java/org/ostrya/presencepublisher/ui/notification/NotificationFactory.java b/app/src/main/java/org/ostrya/presencepublisher/ui/notification/NotificationFactory.java index 14c8463..50bc97b 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/ui/notification/NotificationFactory.java +++ b/app/src/main/java/org/ostrya/presencepublisher/ui/notification/NotificationFactory.java @@ -1,6 +1,8 @@ package org.ostrya.presencepublisher.ui.notification; import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; @@ -12,45 +14,39 @@ import java.text.DateFormat; import java.util.Date; -public class NotificationFactory { +import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static org.ostrya.presencepublisher.Application.MAIN_ACTIVITY_REQUEST_CODE; - public static Notification getServiceNotification(final Context context, final String channelId) { - Intent intent = new Intent(context, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); +public class NotificationFactory { - Notification notification; - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) - .setOngoing(true) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle(context.getString(R.string.app_name)) - .setContentIntent(pendingIntent) - .setOnlyAlertOnce(true); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - notification = builder - .setCategory(Notification.CATEGORY_STATUS) - .build(); - } else { - notification = builder - .build(); + public static void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager != null) { + NotificationChannel channel + = new NotificationChannel(context.getPackageName(), context.getString(R.string.app_name), NotificationManager.IMPORTANCE_DEFAULT); + notificationManager.createNotificationChannel(channel); + } } - return notification; } - public static Notification updateServiceNotification(final Context context, final long lastPing, final long nextPing, - final String channelId) { + + public static Notification getNotification(Context context, long lastSuccess, long nextSchedule) { Intent intent = new Intent(context, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, 0); + intent.setFlags(FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pendingIntent + = PendingIntent.getActivity(context, MAIN_ACTIVITY_REQUEST_CODE, intent, PendingIntent.FLAG_UPDATE_CURRENT); Notification notification; - NotificationCompat.Builder builder = new NotificationCompat.Builder(context, channelId) + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, context.getPackageName()) .setOngoing(true) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(context.getString(R.string.app_name)) - .setContentText(getLastPing(context, lastPing)) + .setContentText(getLastPing(context, lastSuccess)) .setContentIntent(pendingIntent) .setStyle(new NotificationCompat.InboxStyle() - .addLine(getLastPing(context, lastPing)) - .addLine(getNextPing(context, nextPing))) + .addLine(getLastPing(context, lastSuccess)) + .addLine(getNextPing(context, nextSchedule))) .setOnlyAlertOnce(true); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { notification = builder @@ -63,15 +59,15 @@ public static Notification updateServiceNotification(final Context context, fina return notification; } - private static String getLastPing(final Context context, final long lastPing) { - return String.format(context.getString(R.string.notification_last_ping), getFormattedTimestamp(context, lastPing)); + private static String getLastPing(Context context, long lastSuccess) { + return String.format(context.getString(R.string.notification_last_ping), getFormattedTimestamp(context, lastSuccess)); } - private static String getNextPing(final Context context, final long nextPing) { - return String.format(context.getString(R.string.notification_next_ping), getFormattedTimestamp(context, nextPing)); + private static String getNextPing(Context context, long nextSchedule) { + return String.format(context.getString(R.string.notification_next_ping), getFormattedTimestamp(context, nextSchedule)); } - private static String getFormattedTimestamp(final Context context, final long timestamp) { + private static String getFormattedTimestamp(Context context, long timestamp) { if (timestamp == 0L) { return context.getString(R.string.value_undefined); } diff --git a/app/src/main/java/org/ostrya/presencepublisher/ui/preference/MessageSchedulePreference.java b/app/src/main/java/org/ostrya/presencepublisher/ui/preference/MessageSchedulePreference.java index 26af9aa..c548bc9 100644 --- a/app/src/main/java/org/ostrya/presencepublisher/ui/preference/MessageSchedulePreference.java +++ b/app/src/main/java/org/ostrya/presencepublisher/ui/preference/MessageSchedulePreference.java @@ -10,8 +10,8 @@ public class MessageSchedulePreference extends SeekBarPreference { public MessageSchedulePreference(Context context) { super(context); setKey(MESSAGE_SCHEDULE); - setMin(1); - setMax(30); + setMin(15); + setMax(60); setDefaultValue(15); setSeekBarIncrement(1); setShowSeekBarValue(true); diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 1cf9401..8456f91 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -69,4 +69,6 @@ Das Topic, zu dem Batterienachrichten gesendet werden.%nAktueller Wert: %s Batteriestand senden Batteriestand in Prozent zusätzlich zu Präsenznachricht senden. + Akku-Optimierung + Bitte deaktiviere Akku-Optimierung für diese App. Andernfalls kann Android diese App anhalten, sodass keine Nachrichten im Standby gesendet werden. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00cf370..729b9b6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,8 @@ Otherwise, this app will not be able to retrieve the currently connected WiFi name. You can skip enabling it, but then the app will not send any notifications. + Battery optimization + Please disable battery optimization. Otherwise, Android may doze this app and stop it from sending messages during standby. Connection Schedule Last success: %s