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" />
+
+
+
+
уровень
низина
Вы
+ Фоновая работа
+ Telnet, GPS и запись трека при свёрнутом приложении
+ LoraTester активен
+ Работа в фоне
+ трек: %1$d точек
+ трек: нет
+ telnet: выкл
+ Для GPS при выключенном экране разрешите геолокацию «Всегда»
+ Нужен доступ к геолокации для трека и карты
+ Без ограничений батареи
+ Рекомендуется на MIUI / ColorOS / EMUI для стабильного telnet
+ Ограничения батареи уже отключены