diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9b405e0..2612eb7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,12 @@ + + + + + + + diff --git a/app/src/main/java/com/grigowashere/loratester/CommandPoller.java b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java index c68f23c..15a174a 100644 --- a/app/src/main/java/com/grigowashere/loratester/CommandPoller.java +++ b/app/src/main/java/com/grigowashere/loratester/CommandPoller.java @@ -18,6 +18,8 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; public class CommandPoller { @@ -32,6 +34,11 @@ public class CommandPoller { private final TrackRecorder trackRecorder; private final PeerStatsCache peerStatsCache; private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "CommandPoller"); + t.setDaemon(true); + return t; + }); private final Handler mainHandler = new Handler(Looper.getMainLooper()); private final AtomicBoolean running = new AtomicBoolean(false); @@ -75,34 +82,28 @@ public class CommandPoller { if (!running.compareAndSet(false, true)) { return; } - scheduleCommandPoll(); - schedulePairedPoll(); + scheduler.scheduleWithFixedDelay( + this::pollCommandsSafe, 0, COMMAND_POLL_MS, TimeUnit.MILLISECONDS); + scheduler.scheduleWithFixedDelay( + this::pollPairedSafe, 0, PAIRED_POLL_MS, TimeUnit.MILLISECONDS); } public void stop() { running.set(false); } - private void scheduleCommandPoll() { - executor.execute(() -> { - if (running.get()) { - pollCommands(); - } - if (running.get()) { - mainHandler.postDelayed(this::scheduleCommandPoll, COMMAND_POLL_MS); - } - }); + private void pollCommandsSafe() { + if (!running.get()) { + return; + } + pollCommands(); } - private void schedulePairedPoll() { - executor.execute(() -> { - if (running.get()) { - pollPairedSession(); - } - if (running.get()) { - mainHandler.postDelayed(this::schedulePairedPoll, PAIRED_POLL_MS); - } - }); + private void pollPairedSafe() { + if (!running.get()) { + return; + } + pollPairedSession(); } private void pollCommands() { @@ -181,7 +182,7 @@ public class CommandPoller { } startedSessionId = session.id; pendingAckSessionId = session.id; - mainHandler.post(trackRecorder::start); + trackRecorder.start(); } catch (Exception e) { Log.w(TAG, "paired poll failed", e); } diff --git a/app/src/main/java/com/grigowashere/loratester/LoraApp.java b/app/src/main/java/com/grigowashere/loratester/LoraApp.java index e4b72e1..9447149 100644 --- a/app/src/main/java/com/grigowashere/loratester/LoraApp.java +++ b/app/src/main/java/com/grigowashere/loratester/LoraApp.java @@ -3,6 +3,7 @@ package com.grigowashere.loratester; import android.app.Application; import com.grigowashere.loratester.api.ServerApi; +import com.grigowashere.loratester.location.LocationTracker; import com.grigowashere.loratester.net.NetworkMonitor; import com.grigowashere.loratester.track.TrackRecorder; @@ -16,6 +17,7 @@ public class LoraApp extends Application { private NetworkMonitor networkMonitor; private PeerStatsCache peerStatsCache; private CommandPoller commandPoller; + private LocationTracker locationTracker; @Override public void onCreate() { @@ -76,6 +78,22 @@ public class LoraApp extends Application { return commandPoller; } + public synchronized void startLocationUpdates() { + if (locationTracker == null) { + locationTracker = new LocationTracker(this, (lat, lon, alt) -> { + telemetryUploader.updateLocation(lat, lon); + trackRecorder.updateLocation(lat, lon, alt); + }); + } + locationTracker.start(); + } + + public synchronized void stopLocationUpdates() { + if (locationTracker != null) { + locationTracker.stop(); + } + } + public void refreshTrackRecorder() { if (commandPoller != null) { commandPoller.stop(); diff --git a/app/src/main/java/com/grigowashere/loratester/LoraForegroundService.java b/app/src/main/java/com/grigowashere/loratester/LoraForegroundService.java new file mode 100644 index 0000000..50564ff --- /dev/null +++ b/app/src/main/java/com/grigowashere/loratester/LoraForegroundService.java @@ -0,0 +1,178 @@ +package com.grigowashere.loratester; + +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.pm.ServiceInfo; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.PowerManager; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import com.grigowashere.loratester.track.TrackRecorder; + +public class LoraForegroundService extends Service { + + private static final String CHANNEL_ID = "lora_background"; + private static final int NOTIFICATION_ID = 1; + + private final Handler handler = new Handler(Looper.getMainLooper()); + private PowerManager.WakeLock wakeLock; + private LoraApp app; + + private final Runnable notificationTicker = new Runnable() { + @Override + public void run() { + updateNotification(); + handler.postDelayed(this, 5000L); + } + }; + + public static void ensureRunning(Context context) { + Context appContext = context.getApplicationContext(); + Intent intent = new Intent(appContext, LoraForegroundService.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + appContext.startForegroundService(intent); + } else { + appContext.startService(intent); + } + } + + @Override + public void onCreate() { + super.onCreate(); + app = (LoraApp) getApplication(); + createNotificationChannel(); + acquireWakeLock(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Notification notification = buildNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION + | ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ); + } else { + startForeground(NOTIFICATION_ID, notification); + } + app.startLocationUpdates(); + handler.removeCallbacks(notificationTicker); + handler.post(notificationTicker); + return START_STICKY; + } + + @Override + public void onDestroy() { + handler.removeCallbacks(notificationTicker); + releaseWakeLock(); + app.stopLocationUpdates(); + super.onDestroy(); + } + + @Override + public void onTaskRemoved(Intent rootIntent) { + TelemetryUploader uploader = app.getTelemetryUploader(); + if (uploader != null) { + uploader.stopTelnet(); + } + stopForeground(STOP_FOREGROUND_REMOVE); + stopSelf(); + super.onTaskRemoved(rootIntent); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void acquireWakeLock() { + PowerManager pm = (PowerManager) getSystemService(POWER_SERVICE); + if (pm == null) { + return; + } + wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "LoraTester::Background"); + wakeLock.setReferenceCounted(false); + wakeLock.acquire(); + } + + private void releaseWakeLock() { + if (wakeLock != null && wakeLock.isHeld()) { + wakeLock.release(); + } + wakeLock = null; + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ); + channel.setDescription(getString(R.string.notification_channel_desc)); + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) { + nm.createNotificationChannel(channel); + } + } + + private Notification buildNotification() { + Intent open = new Intent(this, MainActivity.class); + open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent pending = PendingIntent.getActivity( + this, + 0, + open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + TelemetryUploader uploader = app.getTelemetryUploader(); + TrackRecorder recorder = app.getTrackRecorder(); + SettingsRepository settings = app.getSettingsRepository(); + + boolean telnetOn = settings.isTelnetEnabled(); + boolean telnetConnected = uploader != null && uploader.isTelnetConnected(); + boolean recording = recorder != null && recorder.isRecording(); + int points = recorder != null ? recorder.getPointCount() : 0; + + String telnetLine = telnetOn + ? getString(telnetConnected ? R.string.telnet_connected : R.string.telnet_disconnected) + : getString(R.string.telnet_disabled_short); + String trackLine = recording + ? getString(R.string.notification_track_recording, points) + : getString(R.string.notification_track_idle); + + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_stat_service) + .setContentTitle(getString(R.string.notification_title)) + .setContentText(telnetLine + " · " + trackLine) + .setSubText(getString(R.string.notification_subtitle)) + .setContentIntent(pending) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .build(); + } + + private void updateNotification() { + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm != null) { + nm.notify(NOTIFICATION_ID, buildNotification()); + } + } +} diff --git a/app/src/main/java/com/grigowashere/loratester/MainActivity.java b/app/src/main/java/com/grigowashere/loratester/MainActivity.java index a4e8ffa..94bdf31 100644 --- a/app/src/main/java/com/grigowashere/loratester/MainActivity.java +++ b/app/src/main/java/com/grigowashere/loratester/MainActivity.java @@ -1,8 +1,14 @@ package com.grigowashere.loratester; import android.Manifest; +import android.content.Intent; import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; import android.os.Bundle; +import android.os.PowerManager; +import android.provider.Settings; +import android.widget.Toast; import androidx.activity.EdgeToEdge; import androidx.activity.result.ActivityResultLauncher; @@ -17,19 +23,30 @@ import androidx.viewpager2.widget.ViewPager2; import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayoutMediator; -import com.grigowashere.loratester.location.LocationTracker; -import com.grigowashere.loratester.track.TrackRecorder; import com.grigowashere.loratester.ui.MainPagerAdapter; public class MainActivity extends AppCompatActivity { - private TelemetryUploader telemetryUploader; - private LocationTracker locationTracker; + private LoraApp app; + private SettingsRepository settings; + private boolean backgroundLocationRequested; private final ActivityResultLauncher locationPermissionLauncher = registerForActivityResult( new ActivityResultContracts.RequestMultiplePermissions(), - result -> startLocationIfPermitted() + result -> onForegroundLocationReady() + ); + + private final ActivityResultLauncher backgroundLocationLauncher = + registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> startBackgroundWork() + ); + + private final ActivityResultLauncher notificationPermissionLauncher = + registerForActivityResult( + new ActivityResultContracts.RequestPermission(), + granted -> startBackgroundWork() ); @Override @@ -43,9 +60,8 @@ public class MainActivity extends AppCompatActivity { return insets; }); - LoraApp app = (LoraApp) getApplication(); - telemetryUploader = app.getTelemetryUploader(); - SettingsRepository settings = app.getSettingsRepository(); + app = (LoraApp) getApplication(); + settings = app.getSettingsRepository(); ViewPager2 pager = findViewById(R.id.viewPager); TabLayout tabs = findViewById(R.id.tabLayout); @@ -62,41 +78,92 @@ public class MainActivity extends AppCompatActivity { tab.setText(titleRes); }).attach(); - TrackRecorder trackRecorder = app.getTrackRecorder(); - locationTracker = new LocationTracker(this, (lat, lon, alt) -> { - telemetryUploader.updateLocation(lat, lon); - trackRecorder.updateLocation(lat, lon, alt); - }); - - requestLocationPermission(); + requestStartupPermissions(); if (settings.isTelnetEnabled()) { - telemetryUploader.startTelnet(); + app.getTelemetryUploader().startTelnet(); } } - private void requestLocationPermission() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + private void requestStartupPermissions() { + if (hasForegroundLocation()) { + onForegroundLocationReady(); + return; + } + locationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + }); + } + + private void onForegroundLocationReady() { + if (!hasForegroundLocation()) { + Toast.makeText(this, R.string.background_location_required, Toast.LENGTH_LONG).show(); + return; + } + requestNotificationPermissionIfNeeded(); + requestBackgroundLocationIfNeeded(); + startBackgroundWork(); + } + + private void requestNotificationPermissionIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + return; + } + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - startLocationIfPermitted(); - } else { - locationPermissionLauncher.launch(new String[]{ - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - }); + return; + } + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS); + } + + private void requestBackgroundLocationIfNeeded() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return; + } + if (hasBackgroundLocation()) { + return; + } + if (backgroundLocationRequested) { + return; + } + backgroundLocationRequested = true; + if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_BACKGROUND_LOCATION)) { + Toast.makeText(this, R.string.background_location_rationale, Toast.LENGTH_LONG).show(); + } + backgroundLocationLauncher.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION); + } + + private void startBackgroundWork() { + LoraForegroundService.ensureRunning(this); + if (settings.isTelnetEnabled()) { + app.getTelemetryUploader().startTelnet(); } } - private void startLocationIfPermitted() { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) - == PackageManager.PERMISSION_GRANTED) { - locationTracker.start(); - } + private boolean hasForegroundLocation() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + == PackageManager.PERMISSION_GRANTED; } - @Override - protected void onDestroy() { - locationTracker.stop(); - telemetryUploader.stopTelnet(); - super.onDestroy(); + private boolean hasBackgroundLocation() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return hasForegroundLocation(); + } + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_BACKGROUND_LOCATION) + == PackageManager.PERMISSION_GRANTED; + } + + public static void openBatteryOptimizationSettings(@NonNull android.content.Context context) { + Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS); + intent.setData(Uri.parse("package:" + context.getPackageName())); + context.startActivity(intent); + } + + public static boolean isIgnoringBatteryOptimizations(@NonNull android.content.Context context) { + PowerManager pm = (PowerManager) context.getSystemService(POWER_SERVICE); + if (pm == null) { + return true; + } + return pm.isIgnoringBatteryOptimizations(context.getPackageName()); } } diff --git a/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java index 0d135ca..62bab5f 100644 --- a/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java +++ b/app/src/main/java/com/grigowashere/loratester/location/LocationTracker.java @@ -33,7 +33,11 @@ public class LocationTracker { } LocationRequest request = new LocationRequest.Builder( Priority.PRIORITY_HIGH_ACCURACY, 10_000L - ).setMinUpdateIntervalMillis(5_000L).build(); + ) + .setMinUpdateIntervalMillis(5_000L) + .setMaxUpdateDelayMillis(15_000L) + .setWaitForAccurateLocation(false) + .build(); callback = new LocationCallback() { @Override diff --git a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java index 0f56ade..2408681 100644 --- a/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java +++ b/app/src/main/java/com/grigowashere/loratester/ui/SettingsFragment.java @@ -15,6 +15,8 @@ import androidx.fragment.app.Fragment; import com.google.android.material.switchmaterial.SwitchMaterial; import com.google.android.material.textfield.TextInputEditText; import com.grigowashere.loratester.LoraApp; +import com.grigowashere.loratester.LoraForegroundService; +import com.grigowashere.loratester.MainActivity; import com.grigowashere.loratester.R; import com.grigowashere.loratester.SettingsRepository; import com.grigowashere.loratester.TelemetryUploader; @@ -44,6 +46,7 @@ public class SettingsFragment extends Fragment { TextInputEditText editRange = view.findViewById(R.id.editRangeRegex); TextInputEditText editDeviceLabel = view.findViewById(R.id.editDeviceLabel); SwitchMaterial switchTelnet = view.findViewById(R.id.switchTelnet); + Button batteryBtn = view.findViewById(R.id.btnBatteryOptimization); TextView deviceIdLabel = view.findViewById(R.id.deviceIdLabel); Button save = view.findViewById(R.id.btnSaveSettings); @@ -59,6 +62,14 @@ public class SettingsFragment extends Fragment { switchTelnet.setChecked(settings.isTelnetEnabled()); deviceIdLabel.setText(getString(R.string.device_id_label, settings.getOrCreateDeviceId())); + batteryBtn.setOnClickListener(v -> { + if (MainActivity.isIgnoringBatteryOptimizations(requireContext())) { + Toast.makeText(requireContext(), R.string.battery_optimization_done, Toast.LENGTH_SHORT).show(); + return; + } + MainActivity.openBatteryOptimizationSettings(requireContext()); + }); + save.setOnClickListener(v -> { settings.setServerUrl(textOf(editServer, SettingsRepository.DEFAULT_SERVER)); settings.setTelnetHost(textOf(editHost, SettingsRepository.DEFAULT_TELNET_HOST)); @@ -78,6 +89,7 @@ public class SettingsFragment extends Fragment { } else { uploader.stopTelnet(); } + LoraForegroundService.ensureRunning(requireContext()); Toast.makeText(requireContext(), R.string.saved, Toast.LENGTH_SHORT).show(); }); } diff --git a/app/src/main/res/drawable/ic_stat_service.xml b/app/src/main/res/drawable/ic_stat_service.xml new file mode 100644 index 0000000..df7129f --- /dev/null +++ b/app/src/main/res/drawable/ic_stat_service.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/fragment_settings.xml b/app/src/main/res/layout/fragment_settings.xml index 9cfd007..a873543 100644 --- a/app/src/main/res/layout/fragment_settings.xml +++ b/app/src/main/res/layout/fragment_settings.xml @@ -93,6 +93,20 @@ android:layout_marginTop="16dp" android:text="@string/telnet_enabled" /> + + +