added sleepmode

This commit is contained in:
2026-06-16 12:42:36 +03:00
parent dbef86d2c9
commit e71b6eed2f
10 changed files with 382 additions and 56 deletions
+10
View File
@@ -6,6 +6,12 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
@@ -30,6 +36,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".LoraForegroundService"
android:exported="false"
android:foregroundServiceType="location|dataSync" />
</application>
</manifest>
@@ -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);
}
@@ -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();
@@ -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());
}
}
}
@@ -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<String[]> locationPermissionLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestMultiplePermissions(),
result -> startLocationIfPermitted()
result -> onForegroundLocationReady()
);
private final ActivityResultLauncher<String> backgroundLocationLauncher =
registerForActivityResult(
new ActivityResultContracts.RequestPermission(),
granted -> startBackgroundWork()
);
private final ActivityResultLauncher<String> 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());
}
}
@@ -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
@@ -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();
});
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,2C8.13,2 5,5.13 5,9c0,5.25 7,13 7,13s7,-7.75 7,-13c0,-3.87 -3.13,-7 -7,-7zM12,11.5c-1.38,0 -2.5,-1.12 -2.5,-2.5s1.12,-2.5 2.5,-2.5 2.5,1.12 2.5,2.5 -1.12,2.5 -2.5,2.5z" />
</vector>
@@ -93,6 +93,20 @@
android:layout_marginTop="16dp"
android:text="@string/telnet_enabled" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="@string/battery_optimization_hint"
android:textSize="12sp" />
<Button
android:id="@+id/btnBatteryOptimization"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/battery_optimization" />
<TextView
android:id="@+id/deviceIdLabel"
android:layout_width="match_parent"
+12
View File
@@ -113,4 +113,16 @@
<string name="map_heatmap_legend_level">уровень</string>
<string name="map_heatmap_legend_low">низина</string>
<string name="chat_self_label">Вы</string>
<string name="notification_channel_name">Фоновая работа</string>
<string name="notification_channel_desc">Telnet, GPS и запись трека при свёрнутом приложении</string>
<string name="notification_title">LoraTester активен</string>
<string name="notification_subtitle">Работа в фоне</string>
<string name="notification_track_recording">трек: %1$d точек</string>
<string name="notification_track_idle">трек: нет</string>
<string name="telnet_disabled_short">telnet: выкл</string>
<string name="background_location_rationale">Для GPS при выключенном экране разрешите геолокацию «Всегда»</string>
<string name="background_location_required">Нужен доступ к геолокации для трека и карты</string>
<string name="battery_optimization">Без ограничений батареи</string>
<string name="battery_optimization_hint">Рекомендуется на MIUI / ColorOS / EMUI для стабильного telnet</string>
<string name="battery_optimization_done">Ограничения батареи уже отключены</string>
</resources>