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 @@
-
-
+ * 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 @@
-
-