diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index d19b6feaf..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index f928e12fb..dcd45ca51 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { targetSdk = 35 - versionCode = 417 - versionName = '2.6.20' + versionCode = 418 + versionName = '2.6.21' multiDexEnabled = true diff --git a/app/src/main/java/com/prey/PreyConfig.java b/app/src/main/java/com/prey/PreyConfig.java index 5624a176b..954945d97 100644 --- a/app/src/main/java/com/prey/PreyConfig.java +++ b/app/src/main/java/com/prey/PreyConfig.java @@ -1673,6 +1673,20 @@ public void setDailyLocation(String dailyLocation){ saveString(PreyConfig.DAILY_LOCATION, dailyLocation); } + public static final String LOCATION_SCHEDULE = "LOCATION_SCHEDULE"; + + /** + * @return the raw {@code location_schedule} JSON from status.json, or "" when unset. + */ + public String getLocationSchedule(){ + return getString(PreyConfig.LOCATION_SCHEDULE, ""); + } + + public void setLocationSchedule(String locationSchedule){ + PreyLogger.d(String.format("DAILY setLocationSchedule [%s]", locationSchedule)); + saveString(PreyConfig.LOCATION_SCHEDULE, locationSchedule); + } + public static final String MINUTES_TO_QUERY_SERVER = "MINUTES_TO_QUERY_SERVER"; public int getMinutesToQueryServer() { diff --git a/app/src/main/java/com/prey/PreyStatus.java b/app/src/main/java/com/prey/PreyStatus.java index 50692285f..8623a5bfe 100644 --- a/app/src/main/java/com/prey/PreyStatus.java +++ b/app/src/main/java/com/prey/PreyStatus.java @@ -80,6 +80,18 @@ public void initConfig(Context ctx){ }catch(Exception e){ aware = false; } + try { + JSONObject jsnobjectLocal = jsnobjectSettings.getJSONObject("local"); + if (jsnobjectLocal.has("location_schedule") + && !jsnobjectLocal.isNull("location_schedule")) { + String locationSchedule = jsnobjectLocal.getJSONObject("location_schedule").toString(); + PreyConfig.getPreyConfig(ctx).setLocationSchedule(locationSchedule); + } else { + PreyConfig.getPreyConfig(ctx).setLocationSchedule(""); + } + }catch(Exception e){ + PreyConfig.getPreyConfig(ctx).setLocationSchedule(""); + } try { JSONObject jsnobjectGlobal = jsnobjectSettings.getJSONObject("global"); autoconnect = jsnobjectGlobal.getBoolean("auto_connect"); diff --git a/app/src/main/java/com/prey/actions/location/daily/AlarmLocationReceiver.java b/app/src/main/java/com/prey/actions/location/daily/AlarmLocationReceiver.java index 50547904c..fb5ed4e75 100644 --- a/app/src/main/java/com/prey/actions/location/daily/AlarmLocationReceiver.java +++ b/app/src/main/java/com/prey/actions/location/daily/AlarmLocationReceiver.java @@ -26,6 +26,8 @@ public void onReceive(Context context, Intent intent) { PreyLogger.d("DAILY______________________________"); PreyLogger.d("DAILY----------AlarmLocationReceiver onReceive"); final Context ctx = context; + // Re-arm the next check first so a failure below never breaks the schedule. + LocationScheduled.getInstance().scheduleNext(ctx); new Thread() { public void run() { new DailyLocation().run(ctx); diff --git a/app/src/main/java/com/prey/actions/location/daily/DailyLocation.java b/app/src/main/java/com/prey/actions/location/daily/DailyLocation.java index c54d019d3..007bb69e0 100644 --- a/app/src/main/java/com/prey/actions/location/daily/DailyLocation.java +++ b/app/src/main/java/com/prey/actions/location/daily/DailyLocation.java @@ -13,7 +13,6 @@ import com.prey.PreyLogger; import com.prey.PreyPhone; import com.prey.actions.location.LocationUpdatesService; -import com.prey.actions.location.LocationUtil; import com.prey.actions.location.PreyLocation; import com.prey.actions.location.PreyLocationManager; import com.prey.net.PreyHttpResponse; @@ -22,53 +21,84 @@ import org.json.JSONObject; import java.net.HttpURLConnection; +import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Locale; +import java.util.TimeZone; public class DailyLocation { + /** Maximum attempts to obtain a fix (widens the acquisition window). */ + private static final int MAXIMUM_OF_ATTEMPTS = 6; + + /** Seconds to wait before reading each attempt. */ + private static final int[] SLEEP_OF_ATTEMPTS = new int[]{2, 2, 3, 3, 4, 4}; + + /** Accuracy (meters) considered good enough to stop early. */ + private static final float GOOD_ACCURACY_METERS = 50f; + + /** + * @return a UTC {@code yyyy-MM-dd} formatter so the "already sent today" boundary is + * evaluated in UTC rather than the device-local timezone. + */ + private static SimpleDateFormat utcDayFormat() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd", Locale.US); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + return sdf; + } + /** * Method checks if it should send a location * * @param context */ public void run(Context context) { + Date now = new Date(); String dailyLocation = PreyConfig.getPreyConfig(context).getDailyLocation(); - String nowDailyLocation = PreyConfig.FORMAT_SDF_AWARE.format(new Date()); + String nowDailyLocation = utcDayFormat().format(now); boolean isAirplaneModeOn = PreyPhone.isAirplaneModeOn(context); - PreyLogger.d(String.format("DailyLocation run isAirplaneModeOn:%s", isAirplaneModeOn)); - if (!nowDailyLocation.equals(dailyLocation) && !isAirplaneModeOn) { + String schedule = PreyConfig.getPreyConfig(context).getLocationSchedule(); + boolean withinWindow = LocationScheduleWindow.isWithinAllowedWindow(schedule, now); + PreyLogger.d(String.format("DailyLocation run isAirplaneModeOn:%s withinWindow:%s", isAirplaneModeOn, withinWindow)); + if (nowDailyLocation.equals(dailyLocation)) { + PreyLogger.d("DAILY location already sent"); + return; + } + if (isAirplaneModeOn || !withinWindow) { + PreyLogger.d("DAILY skipped: airplane mode or outside allowed window"); + return; + } + try { PreyLocationManager.getInstance(context).setLastLocation(null); - try { - PreyLocationManager.getInstance(context).setLastLocation(null); - new LocationUpdatesService().startForegroundService(context); - PreyLocation preyLocation = null; - int i = 0; - while (i < LocationUtil.MAXIMUM_OF_ATTEMPTS) { - PreyLogger.d(String.format("DAILY getPreyLocationApp[%s]", i)); - try { - Thread.sleep(LocationUtil.SLEEP_OF_ATTEMPTS[i] * 1000); - } catch (InterruptedException e) { - PreyLogger.e(String.format("DAILY error :%s", e.getMessage()), e); - } - preyLocation = PreyLocationManager.getInstance(context).getLastLocation(); - if (preyLocation != null) { - preyLocation.setMethod("native"); - } else { - PreyLogger.d(String.format("DAILY null[%s]", i)); - } - if (preyLocation != null && preyLocation.getLat() != 0 && preyLocation.getLng() != 0) { - break; - } - i++; + new LocationUpdatesService().startForegroundService(context); + PreyLocation bestLocation = null; + for (int i = 0; i < MAXIMUM_OF_ATTEMPTS; i++) { + PreyLogger.d(String.format("DAILY getPreyLocationApp[%s]", i)); + try { + Thread.sleep(SLEEP_OF_ATTEMPTS[i] * 1000L); + } catch (InterruptedException e) { + PreyLogger.e(String.format("DAILY error :%s", e.getMessage()), e); + } + PreyLocation preyLocation = PreyLocationManager.getInstance(context).getLastLocation(); + if (preyLocation == null || preyLocation.getLat() == 0 || preyLocation.getLng() == 0) { + PreyLogger.d(String.format("DAILY null[%s]", i)); + continue; } - if (preyLocation != null && preyLocation.getLat() != 0 && preyLocation.getLng() != 0) { - sendLocation(context, preyLocation); + preyLocation.setMethod("native"); + // Keep the most accurate (lowest accuracy value) fix seen so far. + if (bestLocation == null || preyLocation.getAccuracy() < bestLocation.getAccuracy()) { + bestLocation = preyLocation; + } + if (bestLocation.getAccuracy() > 0 && bestLocation.getAccuracy() <= GOOD_ACCURACY_METERS) { + break; } - } catch (Exception e) { - throw new RuntimeException(e); } - } else { - PreyLogger.d("DAILY location already sent"); + // Send the best fix obtained; a mediocre fix still beats skipping the day. + if (bestLocation != null && bestLocation.getLat() != 0 && bestLocation.getLng() != 0) { + sendLocation(context, bestLocation); + } + } catch (Exception e) { + PreyLogger.e(String.format("DAILY run error:%s", e.getMessage()), e); } } @@ -100,7 +130,7 @@ public static void sendLocation(Context context, PreyLocation preyLocation) thro int statusCode = preyResponse.getStatusCode(); PreyLogger.d(String.format("DAILY getStatusCode :%s", statusCode)); if (statusCode == HttpURLConnection.HTTP_OK || statusCode == HttpURLConnection.HTTP_CREATED) { - PreyConfig.getPreyConfig(context).setDailyLocation(PreyConfig.FORMAT_SDF_AWARE.format(new Date())); + PreyConfig.getPreyConfig(context).setDailyLocation(utcDayFormat().format(new Date())); } PreyLogger.d(String.format("DAILY sendNowAware:%s", preyLocation.toString())); } diff --git a/app/src/main/java/com/prey/actions/location/daily/LocationScheduleWindow.java b/app/src/main/java/com/prey/actions/location/daily/LocationScheduleWindow.java new file mode 100644 index 000000000..d930a86fd --- /dev/null +++ b/app/src/main/java/com/prey/actions/location/daily/LocationScheduleWindow.java @@ -0,0 +1,127 @@ +/******************************************************************************* + * Created by Patricio Jofré + * Copyright 2026 Prey Inc. All rights reserved. + * License: GPLv3 + * Full license at "/LICENSE" + ******************************************************************************/ +package com.prey.actions.location.daily; + +import com.prey.PreyLogger; + +import org.json.JSONObject; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +/** + * Pure evaluation of the server-configured allowed window for the daily location. + *

+ * The configuration comes from {@code settings.local.location_schedule} in + * {@code /devices/:key/status.json} and has the shape: + *

+ * {
+ *   "start_at": "10:00", "end_at": "11:00",
+ *   "sunday": false, "monday": true, "tuesday": true, "wednesday": true,
+ *   "thursday": true, "friday": true, "saturday": false
+ * }
+ * 
+ * where each weekday is a boolean flag and {@code start_at}/{@code end_at} are + * {@code "HH:mm"}. Both days and hours are evaluated in UTC and the hour range + * is inclusive on both ends. + *

+ * When the configuration is absent, empty or malformed, the location may be sent at any + * time (fail-open) so the daily-location feature keeps working when no window is set. + */ +public class LocationScheduleWindow { + + /** UTC day-of-week keys, indexed so {@code Calendar.SUNDAY - 1 == 0}. */ + private static final String[] DAY_KEYS = { + "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday" + }; + + private LocationScheduleWindow() { + } + + /** + * Decides whether a location may be acquired/sent at {@code now} given the schedule. + * + * @param scheduleJson the raw {@code location_schedule} JSON, or null/empty when unset + * @param now the instant to evaluate (interpreted in UTC) + * @return true when there is no restriction or {@code now} falls inside the window + */ + public static boolean isWithinAllowedWindow(String scheduleJson, Date now) { + if (scheduleJson == null || scheduleJson.trim().isEmpty()) { + return true; + } + try { + JSONObject schedule = new JSONObject(scheduleJson); + Calendar calendar = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + calendar.setTime(now); + return isDayAllowed(schedule, calendar) && isTimeAllowed(schedule, calendar); + } catch (Exception e) { + PreyLogger.d(String.format("DAILY window malformed schedule, sending ASAP:%s", e.getMessage())); + return true; + } + } + + /** + * @return true when the schedule carries no weekday flags at all (any day), or the flag + * for the current UTC day-of-week is present and {@code true}. A weekday flag that is + * present and {@code false}, or absent while other weekdays are set, blocks that day. + */ + private static boolean isDayAllowed(JSONObject schedule, Calendar calendar) { + boolean hasAnyDayFlag = false; + for (String dayKey : DAY_KEYS) { + if (schedule.has(dayKey)) { + hasAnyDayFlag = true; + break; + } + } + if (!hasAnyDayFlag) { + return true; + } + String today = DAY_KEYS[calendar.get(Calendar.DAY_OF_WEEK) - Calendar.SUNDAY]; // SUNDAY == 1 + return schedule.optBoolean(today, false); + } + + /** + * @return true when {@code start_at}/{@code end_at} are missing/unparseable, or + * {@code start_at <= now <= end_at} in UTC minutes-of-day (inclusive). + */ + private static boolean isTimeAllowed(JSONObject schedule, Calendar calendar) { + int start = parseMinuteOfDay(schedule.optString("start_at", null)); + int end = parseMinuteOfDay(schedule.optString("end_at", null)); + if (start < 0 || end < 0) { + return true; + } + int nowMinutes = calendar.get(Calendar.HOUR_OF_DAY) * 60 + calendar.get(Calendar.MINUTE); + return nowMinutes >= start && nowMinutes <= end; + } + + /** + * Parses a {@code "HH:mm"} string into minutes since midnight. + * + * @return minutes-of-day, or -1 when the value is missing or unparseable + */ + private static int parseMinuteOfDay(String value) { + if (value == null || value.trim().isEmpty()) { + return -1; + } + String[] parts = value.trim().split(":"); + if (parts.length != 2) { + return -1; + } + try { + int hour = Integer.parseInt(parts[0].trim()); + int minute = Integer.parseInt(parts[1].trim()); + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return -1; + } + return hour * 60 + minute; + } catch (NumberFormatException e) { + return -1; + } + } +} diff --git a/app/src/main/java/com/prey/actions/location/daily/LocationScheduled.java b/app/src/main/java/com/prey/actions/location/daily/LocationScheduled.java index 9fecc14e6..eb5f51a36 100644 --- a/app/src/main/java/com/prey/actions/location/daily/LocationScheduled.java +++ b/app/src/main/java/com/prey/actions/location/daily/LocationScheduled.java @@ -10,11 +10,17 @@ import android.app.PendingIntent; import android.content.Context; import android.content.Intent; +import android.os.Build; import com.prey.PreyLogger; public class LocationScheduled { + /** Minutes between daily-location checks. */ + private static final int INTERVAL_MINUTES = 15; + + private static final long INTERVAL_MILLIS = 1000L * 60 * INTERVAL_MINUTES; + private static LocationScheduled instance = null; private LocationScheduled() { @@ -28,26 +34,48 @@ public synchronized static LocationScheduled getInstance() { } /** - * Method that prepares an alarm to send the daily location + * Arms an immediate first daily-location check. Called at app start. The alarm re-arms + * itself from {@link AlarmLocationReceiver} so it survives Doze without a repeating alarm. * * @param context */ public void run(Context context) { + scheduleAt(context, System.currentTimeMillis()); + } + + /** + * Arms the next daily-location check {@code INTERVAL_MINUTES} from now. Called by the + * receiver on each fire so exactly one alarm is ever pending. + * + * @param context + */ + public void scheduleNext(Context context) { + scheduleAt(context, System.currentTimeMillis() + INTERVAL_MILLIS); + } + + /** + * Schedules a single wake-up alarm at {@code triggerAtMillis}. Uses + * {@code setAndAllowWhileIdle} on API 23+ so the alarm fires even in Doze (no exact-alarm + * permission needed); on older APIs Doze does not exist so {@code setExact} is used. + */ + private void scheduleAt(Context context, long triggerAtMillis) { try { - int minute = 15; Intent intent = new Intent(context, AlarmLocationReceiver.class); - PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); + PendingIntent pendingIntent = PendingIntent.getBroadcast(context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); AlarmManager alarmMgr = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.KITKAT) { - PreyLogger.d("DAILY----------LocationScheduled setRepeating"); - alarmMgr.setRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), 1000 * 60 * minute, pendingIntent); + if (alarmMgr == null) { + PreyLogger.d("DAILY----------LocationScheduled no AlarmManager"); + return; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + alarmMgr.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); } else { - PreyLogger.d("DAILY----------LocationScheduled setInexactRepeating"); - alarmMgr.setInexactRepeating(AlarmManager.RTC_WAKEUP, System.currentTimeMillis(), 1000 * 60 * minute, pendingIntent); + alarmMgr.setExact(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); } - PreyLogger.d(String.format("DAILY----------start [%s] LocationScheduled", minute)); + PreyLogger.d(String.format("DAILY----------LocationScheduled scheduled at [%s]", triggerAtMillis)); } catch (Exception e) { PreyLogger.e(String.format("DAILY----------Error LocationScheduled :%s", e.getMessage()), e); } } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/prey/actions/location/daily/LocationScheduleWindowTest.java b/app/src/test/java/com/prey/actions/location/daily/LocationScheduleWindowTest.java new file mode 100644 index 000000000..728fcf0ee --- /dev/null +++ b/app/src/test/java/com/prey/actions/location/daily/LocationScheduleWindowTest.java @@ -0,0 +1,120 @@ +/******************************************************************************* + * Created by Patricio Jofré + * Copyright 2026 Prey Inc. All rights reserved. + * License: GPLv3 + * Full license at "/LICENSE" + ******************************************************************************/ +package com.prey.actions.location.daily; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.TimeZone; + +/** + * Tests for {@link LocationScheduleWindow}, the pure allowed-window evaluation + * used by the daily-location feature. All times are interpreted in UTC. + */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 30) +public class LocationScheduleWindowTest { + + /** Builds a UTC Date for the given day-of-week/hour/minute in a fixed reference week. */ + private static Date utc(int year, int month, int day, int hour, int minute) { + Calendar c = new GregorianCalendar(TimeZone.getTimeZone("UTC")); + c.clear(); + c.set(year, month, day, hour, minute, 0); + return c.getTime(); + } + + // 2026-07-05 is a Sunday, 2026-07-03 is a Friday, + // 2026-07-04 is a Saturday, 2026-07-06 is a Monday. + // Allowed: Sunday, Friday, Saturday between 10:01 and 11:59 UTC. + private static final String CONFIG = + "{\"start_at\":\"10:01\",\"end_at\":\"11:59\"," + + "\"sunday\":true,\"monday\":false,\"tuesday\":false,\"wednesday\":false," + + "\"thursday\":false,\"friday\":true,\"saturday\":true}"; + + @Test + public void nullConfigSendsAsap() { + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(null, utc(2026, 6, 6, 3, 0))); + } + + @Test + public void emptyConfigSendsAsap() { + assertTrue(LocationScheduleWindow.isWithinAllowedWindow("", utc(2026, 6, 6, 3, 0))); + } + + @Test + public void malformedConfigSendsAsap() { + assertTrue(LocationScheduleWindow.isWithinAllowedWindow("{not json", utc(2026, 6, 6, 3, 0))); + } + + @Test + public void allowedDayInsideHoursIsAllowed() { + // Sunday 10:30 UTC + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 5, 10, 30))); + } + + @Test + public void disallowedDayIsBlockedEvenInsideHours() { + // Monday 10:30 UTC — "monday":false + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 6, 10, 30))); + } + + @Test + public void beforeStartIsBlocked() { + // Sunday 10:00 UTC — one minute before start 10:01 + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 5, 10, 0))); + } + + @Test + public void afterEndIsBlocked() { + // Sunday 12:00 UTC — one minute after end 11:59 + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 5, 12, 0))); + } + + @Test + public void startBoundaryIsInclusive() { + // Friday 10:01 UTC exactly + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 3, 10, 1))); + } + + @Test + public void endBoundaryIsInclusive() { + // Saturday 11:59 UTC exactly + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(CONFIG, utc(2026, 6, 4, 11, 59))); + } + + @Test + public void daysOnlyConfigAllowsAnyTimeOnAllowedDay() { + String daysOnly = "{\"sunday\":true,\"monday\":false}"; + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(daysOnly, utc(2026, 6, 5, 3, 0))); + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(daysOnly, utc(2026, 6, 6, 3, 0))); + } + + @Test + public void hoursOnlyConfigAllowsAnyDayInsideHours() { + String hoursOnly = "{\"start_at\":\"10:01\",\"end_at\":\"11:59\"}"; + // Monday is fine because no weekday flags are set, and 10:30 is inside hours + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(hoursOnly, utc(2026, 6, 6, 10, 30))); + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(hoursOnly, utc(2026, 6, 6, 9, 0))); + } + + @Test + public void dayNotPresentWhileOtherDaysSetIsBlocked() { + // Only weekdays enabled; Sunday flag absent -> Sunday blocked. + String weekdays = "{\"monday\":true,\"tuesday\":true,\"wednesday\":true," + + "\"thursday\":true,\"friday\":true}"; + assertFalse(LocationScheduleWindow.isWithinAllowedWindow(weekdays, utc(2026, 6, 5, 3, 0))); + assertTrue(LocationScheduleWindow.isWithinAllowedWindow(weekdays, utc(2026, 6, 6, 3, 0))); + } +} diff --git a/prey-android-client.iml b/prey-android-client.iml deleted file mode 100644 index 0889bd40f..000000000 --- a/prey-android-client.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file