diff --git a/app/build.gradle b/app/build.gradle index 3fe04cf..388d966 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,6 +20,12 @@ android { } } namespace = 'cl.coders.faketraveler' + + testOptions { + unitTests { + returnDefaultValues = true + } + } } dependencies { @@ -27,6 +33,9 @@ dependencies { implementation 'androidx.appcompat:appcompat:1.7.1' implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'com.google.android.flexbox:flexbox:3.0.0' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.mockito:mockito-core:5.14.2' } java { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d1d8a1..c323e96 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -9,6 +9,9 @@ tools:ignore="MockLocation,ProtectedPermissions" /> + + + - + android:enabled="true" + android:foregroundServiceType="location" /> diff --git a/app/src/main/assets/map.html b/app/src/main/assets/map.html index ce360d3..20badbc 100644 --- a/app/src/main/assets/map.html +++ b/app/src/main/assets/map.html @@ -20,30 +20,54 @@ display: flex; flex-direction: column; } + :root { + --search-bg: #fff; + --search-fg: #000; + --search-border: #ccc; + --dark-mode-filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%); + } #search { padding: 10px; /* Add some padding around the input */ box-sizing: border-box; /* Include padding in element's total width and height */ + background: var(--search-bg); } #address_input { font-size: 16pt; width: 100%; box-sizing: border-box; + background: var(--search-bg); + color: var(--search-fg); + border: 1px solid var(--search-border); + border-radius: 4px; + padding: 4px 8px; + } + #address_input::placeholder { + color: var(--search-fg); + opacity: 0.6; + } + #search button { + background: var(--search-bg); + color: var(--search-fg); + border: 1px solid var(--search-border); + border-radius: 4px; + cursor: pointer; } #map { flex-grow: 1; width: 100%; } - :root { - --dark-mode-filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%); - } @media (prefers-color-scheme: dark) { + :root { + --search-bg: #000; + --search-fg: #fff; + --search-border: #444; + } .map-tiles, .leaflet-layer, .leaflet-control-zoom-in, .leaflet-control-zoom-out { filter:var(--dark-mode-filter, none); } - #search, .leaflet-container { background: #000 } diff --git a/app/src/main/java/cl/coders/faketraveler/KmzFile.java b/app/src/main/java/cl/coders/faketraveler/KmzFile.java new file mode 100644 index 0000000..c7eb32c --- /dev/null +++ b/app/src/main/java/cl/coders/faketraveler/KmzFile.java @@ -0,0 +1,399 @@ +package cl.coders.faketraveler; + +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.xml.sax.Attributes; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +import java.io.FilterInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import javax.xml.parsers.SAXParser; +import javax.xml.parsers.SAXParserFactory; + +/** + * Represents a KMZ file selected by the user through the Storage Access Framework. + * Wraps a content {@link Uri} and exposes convenience methods to query metadata + * such as the file size. + */ +public class KmzFile { + + @NonNull + private static final String TAG = KmzFile.class.getSimpleName(); + + /** + * Mean Earth radius in kilometres (IUGG). Used by the haversine distance + * calculation in {@link #distanceKm(Point, Point)} and exposed + * package-private so tests can derive expected geographic distances from + * the exact same value the production code uses. + */ + static final double EARTH_RADIUS_KM = 6371.0088; + + /** MIME types accepted when launching a document picker for KMZ files. */ + @NonNull + public static final String[] MIME_TYPES = { + "application/vnd.google-earth.kmz", + "application/octet-stream", + "*/*" + }; + + @NonNull + private final Context context; + @NonNull + private final Uri uri; + + public KmzFile(@NonNull Context context, @NonNull Uri uri) { + this.context = context.getApplicationContext(); + this.uri = uri; + } + + /** + * Opens the KMZ file previously granted via the Storage Access Framework, or + * {@code null} if no file has been persisted yet. + */ + @Nullable + public static KmzFile openPersisted(@NonNull Context context, @Nullable String uriString) { + if (uriString == null) return null; + try { + return new KmzFile(context, Uri.parse(uriString)); + } catch (Throwable t) { + Log.e(TAG, "Could not parse persisted KMZ URI: " + uriString, t); + return null; + } + } + + /** + * Persists read permission on this file across process restarts. + */ + public void takePersistablePermission() { + try { + context.getContentResolver().takePersistableUriPermission( + uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + } catch (Throwable t) { + Log.w(TAG, "Could not persist URI permission for " + uri, t); + } + } + + @NonNull + public Uri getUri() { + return uri; + } + + /** + * @return the file size in bytes, or {@code -1} if it cannot be determined. + */ + public long getSize() { + ContentResolver resolver = context.getContentResolver(); + try (Cursor cursor = resolver.query(uri, null, null, null, null)) { + if (cursor != null && cursor.moveToFirst()) { + int sizeIdx = cursor.getColumnIndex(OpenableColumns.SIZE); + if (sizeIdx >= 0 && !cursor.isNull(sizeIdx)) { + return cursor.getLong(sizeIdx); + } + } + } catch (Throwable t) { + Log.e(TAG, "Could not read KMZ file size for " + uri, t); + } + return -1; + } + + /** + * Reads the first {@code } element found under + * {@code kml > Document > Placemark} in the KML entry of the KMZ archive. + * + * @return the placemark name, or {@code null} if none could be read + * (missing entry, malformed XML, no matching element, etc.). + */ + @Nullable + public String getPlacemarkName() { + return parseKmlEntry(new NameHandler()); + } + + /** + * Reads all points from the first {@code } found + * under {@code kml > Document > Placemark} in the KML entry of the KMZ + * archive. + * + *

KML coordinate tuples are {@code longitude,latitude[,altitude]}, + * whitespace-separated.

+ * + * @return an immutable list of points; empty when nothing could be read + * (missing entry, malformed XML, no matching element, etc.). + */ + @NonNull + public List getLinePoints() { + String raw = parseKmlEntry(new CoordinatesHandler()); + if (raw == null || raw.isBlank()) return Collections.emptyList(); + List points = new ArrayList<>(); + for (String tuple : raw.trim().split("\\s+")) { + if (tuple.isEmpty()) continue; + String[] parts = tuple.split(","); + if (parts.length < 2) continue; + try { + double lon = Double.parseDouble(parts[0]); + double lat = Double.parseDouble(parts[1]); + double alt = parts.length >= 3 ? Double.parseDouble(parts[2]) : 0d; + points.add(new Point(lon, lat, alt)); + } catch (NumberFormatException e) { + Log.w(TAG, "Skipping malformed KML coordinate: " + tuple, e); + } + } + return Collections.unmodifiableList(points); + } + + /** + * A single point extracted from a KML {@code } tuple. + * + *

KML uses {@code longitude,latitude[,altitude]} ordering.

+ */ + public record Point(double lon, double lat, double alt) {} + + /** + * Samples points along this KMZ's LineString as if a vehicle were driving + * at {@code speedKmh} kilometres per hour, emitting one position every + * {@code intervalSeconds} seconds. + * + *

The returned list always starts with the first point of the + * LineString (time = 0) and ends with the last point of the LineString + * (arrival), with intermediate positions interpolated linearly along the + * polyline segments.

+ * + * @return an immutable list of sampled points; empty when the LineString + * cannot be read. + */ + @NonNull + public List samplePoints(double speedKmh, double intervalSeconds) { + return sampleAlongLine(getLinePoints(), speedKmh, intervalSeconds); + } + + /** + * Package-private helper that performs the sampling on a supplied + * polyline. Extracted for direct unit testing. + */ + @NonNull + static List sampleAlongLine(@NonNull List line, + double speedKmh, + double intervalSeconds) { + if (line.isEmpty()) return Collections.emptyList(); + if (line.size() == 1 || speedKmh <= 0 || intervalSeconds <= 0) { + return Collections.singletonList(line.get(0)); + } + final double stepKm = speedKmh * intervalSeconds / 3600.0; + if (stepKm <= 0) return Collections.singletonList(line.get(0)); + + List result = new ArrayList<>(); + result.add(line.get(0)); + + int segIdx = 0; // current segment start + double segOffset = 0; // distance already consumed within current segment + double segLen = distanceKm(line.get(0), line.get(1)); // length of current segment + + while (true) { + double remaining = stepKm; + // Advance across segments until we've walked `remaining` km. + // Using >= collapses the exact-boundary case (step lands on a + // vertex, or on the very end of the line) so we don't emit the + // final vertex twice on the next outer iteration. + while (remaining >= segLen - segOffset) { + remaining -= (segLen - segOffset); + segIdx++; + segOffset = 0; + if (segIdx >= line.size() - 1) { + result.add(line.get(line.size() - 1)); + return Collections.unmodifiableList(result); + } + segLen = distanceKm(line.get(segIdx), line.get(segIdx + 1)); + } + segOffset += remaining; + double t = segLen > 0 ? segOffset / segLen : 0d; + result.add(interpolate(line.get(segIdx), line.get(segIdx + 1), t)); + } + } + + /** Great-circle distance between two points in kilometres (haversine). */ + private static double distanceKm(@NonNull Point a, @NonNull Point b) { + double lat1 = Math.toRadians(a.lat()); + double lat2 = Math.toRadians(b.lat()); + double dLat = lat2 - lat1; + double dLon = Math.toRadians(b.lon() - a.lon()); + double s = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) * Math.sin(dLon / 2); + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1d, Math.sqrt(s))); + } + + /** + * Initial bearing (forward azimuth) from {@code a} to {@code b}, in + * degrees clockwise from true north, in the range {@code [0, 360)}. + * Returns {@code 0} if the two points are identical. + */ + public static float bearingDegrees(@NonNull Point a, @NonNull Point b) { + double lat1 = Math.toRadians(a.lat()); + double lat2 = Math.toRadians(b.lat()); + double dLon = Math.toRadians(b.lon() - a.lon()); + double y = Math.sin(dLon) * Math.cos(lat2); + double x = Math.cos(lat1) * Math.sin(lat2) + - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon); + if (y == 0 && x == 0) return 0f; + double deg = Math.toDegrees(Math.atan2(y, x)); + return (float) ((deg + 360d) % 360d); + } + + /** Linear interpolation between two points, {@code t} in {@code [0, 1]}. */ + @NonNull + private static Point interpolate(@NonNull Point a, @NonNull Point b, double t) { + return new Point( + a.lon() + (b.lon() - a.lon()) * t, + a.lat() + (b.lat() - a.lat()) * t, + a.alt() + (b.alt() - a.alt()) * t); + } + + /** + * Opens the first {@code .kml} entry in the archive and feeds it to the + * given handler. Returns the handler's captured result, or {@code null} + * on any error. + */ + @Nullable + private String parseKmlEntry(@NonNull H handler) { + ContentResolver resolver = context.getContentResolver(); + try (InputStream raw = resolver.openInputStream(uri)) { + if (raw == null) { + Log.w(TAG, "Could not open KMZ stream for " + uri); + return null; + } + try (ZipInputStream zis = new ZipInputStream(raw)) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String name = entry.getName(); + if (name != null && name.toLowerCase().endsWith(".kml")) { + try { + SAXParser parser = SAXParserFactory.newInstance().newSAXParser(); + parser.parse(new InputSource(new NonClosingInputStream(zis)), handler); + } catch (StopParsingException stop) { + // Handler signalled it captured the desired value. + } + return handler.getResult(); + } + } + } + } catch (Throwable t) { + Log.e(TAG, "Could not parse KML entry from " + uri, t); + } + return null; + } + + /** Marks a SAX handler that exposes a single captured string result. */ + private interface ResultProvider { + @Nullable String getResult(); + } + + /** + * SAX handler that captures the first {@code } text found within the + * path {@code kml/Document/Placemark}. Element matching is + * case-insensitive and ignores XML namespaces. + */ + private static final class NameHandler extends PathTextHandler { + NameHandler() { super("kml", "document", "placemark", "name"); } + } + + /** + * SAX handler that captures the first {@code } text found + * within {@code kml/Document/Placemark/LineString}. + */ + private static final class CoordinatesHandler extends PathTextHandler { + CoordinatesHandler() { super("kml", "document", "placemark", "linestring", "coordinates"); } + } + + /** + * Base SAX handler that captures the concatenated text content of the + * first element that matches a fixed lower-cased element path (namespace + * agnostic). Parsing is short-circuited via {@link StopParsingException} + * once the value is captured. + */ + private static class PathTextHandler extends DefaultHandler implements ResultProvider { + @NonNull private final String[] path; + @NonNull private final Deque stack = new ArrayDeque<>(); + @Nullable private StringBuilder currentText; + @Nullable private String result; + + PathTextHandler(@NonNull String... path) { this.path = path; } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attributes) { + stack.push(pickLocalName(localName, qName).toLowerCase()); + if (result == null && matchesPath()) { + currentText = new StringBuilder(); + } + } + + @Override + public void characters(char[] ch, int start, int length) { + if (currentText != null) currentText.append(ch, start, length); + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + if (currentText != null) { + result = currentText.toString().trim(); + currentText = null; + if (!stack.isEmpty()) stack.pop(); + throw new StopParsingException(); + } + if (!stack.isEmpty()) stack.pop(); + } + + @Nullable + @Override + public String getResult() { return result; } + + private boolean matchesPath() { + if (stack.size() != path.length) return false; + int i = path.length - 1; + for (String tag : stack) { + if (!path[i--].equals(tag)) return false; + } + return true; + } + + @NonNull + private static String pickLocalName(@Nullable String localName, @Nullable String qName) { + if (localName != null && !localName.isEmpty()) return localName; + if (qName == null) return ""; + int colon = qName.indexOf(':'); + return colon >= 0 ? qName.substring(colon + 1) : qName; + } + } + + /** Thrown to short-circuit SAX parsing once the desired value has been found. */ + private static final class StopParsingException extends SAXException {} + + /** Wrapper that prevents the SAX parser from closing the underlying stream. */ + private static final class NonClosingInputStream extends FilterInputStream { + NonClosingInputStream(@NonNull InputStream in) { super(in); } + @Override public void close() throws IOException { /* no-op */ } + } +} + + + + + diff --git a/app/src/main/java/cl/coders/faketraveler/MainActivity.java b/app/src/main/java/cl/coders/faketraveler/MainActivity.java index 823ea7b..54d6153 100644 --- a/app/src/main/java/cl/coders/faketraveler/MainActivity.java +++ b/app/src/main/java/cl/coders/faketraveler/MainActivity.java @@ -23,25 +23,36 @@ import android.os.Bundle; import android.os.IBinder; import android.text.Editable; +import android.text.Html; import android.text.TextWatcher; +import android.text.method.LinkMovementMethod; import android.util.Log; +import android.view.View; import android.webkit.WebChromeClient; import android.webkit.WebView; +import android.widget.CheckBox; import android.widget.EditText; +import android.widget.RadioGroup; +import android.widget.ScrollView; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; +import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.button.MaterialButton; import com.google.android.material.snackbar.Snackbar; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; +import java.util.List; import java.util.Locale; @@ -56,9 +67,28 @@ public class MainActivity extends AppCompatActivity implements ServiceConnection private WebView webView; private EditText editTextLat; private EditText editTextLng; + private View mapContainer; + private View latLonControls; + private ScrollView settingsContainer; + private BottomNavigationView bottomNav; private Context context; private int currentVersion; + @Nullable + private TextView tvKmzFileSize; + @Nullable + private TextView tvKmzPlacemarkName; + /** LineString points parsed from the currently opened KMZ (File mode). */ + @Nullable + private List currentKmzLine; + + private final ActivityResultLauncher openKmzLauncher = + registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { + if (uri != null) { + onKmzFileSelected(new KmzFile(this, uri)); + } + }); + @NonNull private SourceChange srcChange = NONE; @@ -98,18 +128,31 @@ protected void onCreate(Bundle savedInstanceState) { WebAppInterface webAppInterface = new WebAppInterface(this); buttonApplyStop = findViewById(R.id.button_applyStop); - MaterialButton buttonSettings = findViewById(R.id.button_settings); editTextLat = findViewById(R.id.editTextLat); editTextLng = findViewById(R.id.editTextLng); - - buttonApplyStop.setOnClickListener(view -> { - Intent intent = new Intent(this, MockedLocationService.class); - bindService(intent, this, BIND_AUTO_CREATE); - }); - buttonSettings.setOnClickListener(view -> { - Intent myIntent = new Intent(getBaseContext(), MoreActivity.class); - startActivity(myIntent); + mapContainer = findViewById(R.id.map_container); + latLonControls = findViewById(R.id.latlon_controls); + settingsContainer = findViewById(R.id.settings_container); + bottomNav = findViewById(R.id.bottom_nav); + + buttonApplyStop.setOnClickListener(view -> startAndBindMockService()); + + bottomNav.setOnItemSelectedListener(item -> { + int id = item.getItemId(); + if (id == R.id.nav_search) { + showSearchTab(); + return true; + } else if (id == R.id.nav_latlon) { + showLatLonTab(); + return true; + } else if (id == R.id.nav_settings) { + showSettingsTab(); + return true; + } + return false; }); + // Search is selected by default + bottomNav.setSelectedItemId(R.id.nav_search); webView.getSettings().setJavaScriptEnabled(true); webView.setWebChromeClient(new WebChromeClient()); @@ -186,6 +229,7 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { saveSettings(); } + initSettingsTab(); } @Override @@ -286,6 +330,16 @@ private void applyIntentOrDefault(Intent intent) { * This method is called when "Apply" button is pressed. */ protected void applyLocation() { + if (binder == null) return; + + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + boolean isFileMode = "file".equals(sp.getString("mode", "point")); + + if (isFileMode && currentKmzLine != null && !currentKmzLine.isEmpty()) { + applyRouteFromKmz(); + return; + } + if (latIsEmpty() || lngIsEmpty()) { showSnackbar(context.getResources().getString(R.string.MainActivity_NoLatLong)); return; @@ -294,16 +348,56 @@ protected void applyLocation() { lat = Double.parseDouble(editTextLat.getText().toString()); lng = Double.parseDouble(editTextLng.getText().toString()); - if (binder != null) { - float[] speed = {0}; - if (mockSpeed) { - Location.distanceBetween(lat, lng, lat + dLat / 1000000, lng + dLng / 1000000, speed); - speed[0] /= mockFrequency * 1000L; - } - binder.startMock(lng, lat, dLng / 1000000, dLat / 1000000, mockFrequency * 1000L, mockCount, speed[0]); - endTime = System.currentTimeMillis() + (mockCount - 1L) * mockFrequency * 1000L; - saveSettings(); + float[] speed = {0}; + if (mockSpeed) { + Location.distanceBetween(lat, lng, lat + dLat / 1000000, lng + dLng / 1000000, speed); + speed[0] /= mockFrequency * 1000L; } + binder.startMock(lng, lat, dLng / 1000000, dLat / 1000000, mockFrequency * 1000L, mockCount, speed[0]); + endTime = System.currentTimeMillis() + (mockCount - 1L) * mockFrequency * 1000L; + saveSettings(); + } + + /** + * File-mode Apply: samples the previously-parsed KMZ LineString at the + * configured speed and interval, then dispatches those points to the + * mock-location service. + */ + private void applyRouteFromKmz() { + if (binder == null || currentKmzLine == null || currentKmzLine.isEmpty()) return; + + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + int frequencyS = sp.getInt("mockFrequency", 10); + if (frequencyS <= 0) frequencyS = 1; + double speedKmh = getDouble(sp, "movementSpeedKmh", 60); + int secondsBeforeStart = Math.max(0, sp.getInt("secondsBeforeStart", 5)); + + List samples = KmzFile.sampleAlongLine(currentKmzLine, speedKmh, frequencyS); + if (samples.isEmpty()) { + showSnackbar(context.getResources().getString(R.string.MainActivity_NoLatLong)); + return; + } + + // Hold on the starting point for the requested extra time by + // prepending copies of the first sample. Number of extra ticks is + // ceil(secondsBeforeStart / frequencyS) so we always cover at least + // the requested duration even when it isn't a multiple of the tick. + int extraTicks = secondsBeforeStart == 0 + ? 0 + : (int) Math.ceil(secondsBeforeStart / (double) frequencyS); + if (extraTicks > 0) { + java.util.ArrayList padded = + new java.util.ArrayList<>(samples.size() + extraTicks); + KmzFile.Point first = samples.get(0); + for (int i = 0; i < extraTicks; i++) padded.add(first); + padded.addAll(samples); + samples = padded; + } + + float speedMs = mockSpeed ? (float) (speedKmh * 1000d / 3600d) : 0f; + binder.startRoute(samples, frequencyS * 1000L, speedMs); + endTime = System.currentTimeMillis() + (samples.size() - 1L) * frequencyS * 1000L; + saveSettings(); } /** @@ -346,8 +440,7 @@ void changeButtonToApply() { buttonApplyStop.setText(context.getResources().getString(R.string.ActivityMain_Apply)); buttonApplyStop.setOnClickListener(view -> { if (binder == null) { - Intent intent = new Intent(this, MockedLocationService.class); - bindService(intent, this, BIND_AUTO_CREATE); + startAndBindMockService(); } else { binder.continueMock(); } @@ -360,11 +453,29 @@ void changeButtonToApply() { void changeButtonToStop() { buttonApplyStop.setText(context.getResources().getString(R.string.ActivityMain_Stop)); buttonApplyStop.setOnClickListener(view -> { - unbindService(this); + try { + unbindService(this); + } catch (Throwable ignored) { /* not bound */ } + stopService(new Intent(this, MockedLocationService.class)); disconnectService(); }); } + /** + * Starts the mocking service as a foreground service (so it survives the + * activity going to background / being destroyed) and binds to it to + * receive state and location updates. + */ + private void startAndBindMockService() { + Intent intent = new Intent(this, MockedLocationService.class); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + startForegroundService(intent); + } else { + startService(intent); + } + bindService(intent, this, BIND_AUTO_CREATE); + } + public void setZoom(double zoom) { this.zoom = zoom; saveSettings(); @@ -433,11 +544,348 @@ private void indicateMockStop() { } private void onMockedLocationChange(Location location) { - setMapMarker(location.getLatitude(), location.getLongitude()); + // LOAD updates both the map marker and the Lat/Lon EditTexts (using + // srcChange = CHANGE_FROM_MAP internally so the text-watchers don't + // treat this as a manual edit and loop back into setLatLng). + setLatLng(location.getLatitude(), location.getLongitude(), LOAD); } public enum SourceChange { NONE, LOAD, CHANGE_FROM_EDITTEXT, CHANGE_FROM_MAP } + // ---------------------- Tab handling ---------------------- + + private void showSearchTab() { + mapContainer.setVisibility(View.VISIBLE); + latLonControls.setVisibility(View.GONE); + settingsContainer.setVisibility(View.GONE); + setMapSearchBarVisible(true); + } + + private void showLatLonTab() { + mapContainer.setVisibility(View.VISIBLE); + latLonControls.setVisibility(View.VISIBLE); + settingsContainer.setVisibility(View.GONE); + setMapSearchBarVisible(false); + } + + private void showSettingsTab() { + mapContainer.setVisibility(View.GONE); + latLonControls.setVisibility(View.GONE); + settingsContainer.setVisibility(View.VISIBLE); + } + + /** + * Toggles the map.html in-page search box (since the Search tab uses it and the + * Lat/Lon tab should not show it). + */ + private void setMapSearchBarVisible(boolean visible) { + if (webView == null) return; + String display = visible ? "flex" : "none"; + webView.evaluateJavascript( + "(function(){var s=document.getElementById('search');if(s){s.style.display='" + display + "';}})();", + null); + } + + // ---------------------- Settings tab ---------------------- + + private void initSettingsTab() { + SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + + View pointSettings = findViewById(R.id.point_settings); + View fileSettings = findViewById(R.id.file_settings); + RadioGroup rgMode = findViewById(R.id.rg_Mode); + boolean isFileMode = "file".equals(sharedPref.getString("mode", "point")); + if (isFileMode) { + rgMode.check(R.id.rb_ModeFile); + pointSettings.setVisibility(View.GONE); + fileSettings.setVisibility(View.VISIBLE); + } else { + rgMode.check(R.id.rb_ModePoint); + pointSettings.setVisibility(View.VISIBLE); + fileSettings.setVisibility(View.GONE); + } + rgMode.setOnCheckedChangeListener((group, checkedId) -> { + boolean pointChecked = checkedId == R.id.rb_ModePoint; + pointSettings.setVisibility(pointChecked ? View.VISIBLE : View.GONE); + fileSettings.setVisibility(pointChecked ? View.GONE : View.VISIBLE); + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + sp.edit().putString("mode", pointChecked ? "point" : "file").apply(); + }); + + tvKmzFileSize = findViewById(R.id.tv_KmzFileSize); + tvKmzPlacemarkName = findViewById(R.id.tv_KmzPlacemarkName); + MaterialButton buttonOpenKmz = findViewById(R.id.button_openKmz); + buttonOpenKmz.setOnClickListener(v -> openKmzLauncher.launch(KmzFile.MIME_TYPES)); + + EditText etFileMockFrequency = findViewById(R.id.et_FileMockFrequency); + etFileMockFrequency.setText(String.format(Locale.ROOT, "%d", + sharedPref.getInt("mockFrequency", 10))); + etFileMockFrequency.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etFileMockFrequency.getText().toString().isBlank()) { + editor.putInt("mockFrequency", 10); + } else { + try { + editor.putInt("mockFrequency", + Integer.parseInt(etFileMockFrequency.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse mockFrequency (file)!", t); + } + } + editor.apply(); + } + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + EditText etFileMovementSpeed = findViewById(R.id.et_FileMovementSpeed); + etFileMovementSpeed.setText(DECIMAL_FORMAT.format( + getDouble(sharedPref, "movementSpeedKmh", 60))); + etFileMovementSpeed.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etFileMovementSpeed.getText().toString().isBlank()) { + putDouble(editor, "movementSpeedKmh", 60); + } else { + try { + putDouble(editor, "movementSpeedKmh", + Double.parseDouble(etFileMovementSpeed.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse movementSpeedKmh!", t); + } + } + editor.apply(); + } + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + EditText etFileSecondsBeforeStart = findViewById(R.id.et_FileSecondsBeforeStart); + etFileSecondsBeforeStart.setText(String.format(Locale.ROOT, "%d", + sharedPref.getInt("secondsBeforeStart", 5))); + etFileSecondsBeforeStart.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etFileSecondsBeforeStart.getText().toString().isBlank()) { + editor.putInt("secondsBeforeStart", 5); + } else { + try { + editor.putInt("secondsBeforeStart", + Integer.parseInt(etFileSecondsBeforeStart.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse secondsBeforeStart!", t); + } + } + editor.apply(); + } + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + // Restore previously opened KMZ info, if any + KmzFile savedKmz = KmzFile.openPersisted(context, sharedPref.getString("kmzUri", null)); + if (savedKmz != null) { + displayKmzInfo(savedKmz); + } + + TextView tvLeafletLicense = findViewById(R.id.tv_LeafletLicense); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + tvLeafletLicense.setText(Html.fromHtml(getString(R.string.Settings_LeafletLicense), + Html.FROM_HTML_MODE_LEGACY)); + } else { + tvLeafletLicense.setText(Html.fromHtml(getString(R.string.Settings_LeafletLicense))); + } + tvLeafletLicense.setMovementMethod(LinkMovementMethod.getInstance()); + + EditText etDMockLat = findViewById(R.id.et_DMockLat); + etDMockLat.setText(DECIMAL_FORMAT.format(getDouble(sharedPref, "dLat", 0))); + etDMockLat.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etDMockLat.getText().toString().isBlank()) { + putDouble(editor, "dLat", 0); + } else { + try { + putDouble(editor, "dLat", Double.parseDouble(etDMockLat.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse dLat!", t); + } + } + editor.apply(); + } + + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + EditText etDMockLon = findViewById(R.id.et_DMockLon); + etDMockLon.setText(DECIMAL_FORMAT.format(getDouble(sharedPref, "dLng", 0))); + etDMockLon.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etDMockLon.getText().toString().isBlank()) { + putDouble(editor, "dLng", 0); + } else { + try { + putDouble(editor, "dLng", Double.parseDouble(etDMockLon.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse dLng!", t); + } + } + editor.apply(); + } + + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + EditText etMockCount = findViewById(R.id.et_MockCount); + etMockCount.setText(String.format(Locale.ROOT, "%d", sharedPref.getInt("mockCount", 0))); + etMockCount.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etMockCount.getText().toString().isBlank()) { + editor.putInt("mockCount", 0); + } else { + try { + editor.putInt("mockCount", Integer.parseInt(etMockCount.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse mockCount!", t); + } + } + editor.apply(); + } + + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + EditText etMockFrequency = findViewById(R.id.et_MockFrequency); + etMockFrequency.setText(String.format(Locale.ROOT, "%d", sharedPref.getInt("mockFrequency", 10))); + etMockFrequency.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etMockFrequency.getText().toString().isBlank()) { + editor.putInt("mockFrequency", 10); + } else { + try { + editor.putInt("mockFrequency", Integer.parseInt(etMockFrequency.getText().toString())); + } catch (Throwable t) { + Log.e(MainActivity.class.toString(), "Could not parse mockFrequency!", t); + } + } + editor.apply(); + } + + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + + CheckBox cbMockSpeed = findViewById(R.id.cb_MockSpeed); + cbMockSpeed.setChecked(sharedPref.getBoolean("mockSpeed", true)); + cbMockSpeed.setOnCheckedChangeListener((compoundButton, b) -> { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + editor.putBoolean("mockSpeed", cbMockSpeed.isChecked()); + editor.apply(); + }); + + EditText etMapProvider = findViewById(R.id.et_MapProvider); + etMapProvider.setText(sharedPref.getString("mapProvider", + MapProviderUtil.getDefaultMapProvider(Locale.getDefault()))); + etMapProvider.addTextChangedListener(new TextWatcher() { + @Override + public void afterTextChanged(Editable s) { + SharedPreferences sp = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); + Editor editor = sp.edit(); + if (etMapProvider.getText().toString().isBlank()) { + editor.putString("mapProvider", MapProviderUtil.getDefaultMapProvider(Locale.getDefault())); + } else { + editor.putString("mapProvider", etMapProvider.getText().toString()); + } + editor.apply(); + } + + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} + }); + } + + private void onKmzFileSelected(@NonNull KmzFile kmz) { + kmz.takePersistablePermission(); + context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE) + .edit().putString("kmzUri", kmz.getUri().toString()).apply(); + displayKmzInfo(kmz); + } + + private void displayKmzInfo(@NonNull KmzFile kmz) { + displayKmzFileSize(kmz); + loadKmzAsyncAndUpdateUi(kmz); + } + + private void displayKmzFileSize(@NonNull KmzFile kmz) { + if (tvKmzFileSize == null) return; + long size = kmz.getSize(); + if (size >= 0) { + tvKmzFileSize.setText(getString(R.string.Settings_KmzFileSize, humanReadableSize(size))); + tvKmzFileSize.setVisibility(View.VISIBLE); + } else { + tvKmzFileSize.setVisibility(View.GONE); + } + } + + /** + * Reads the placemark name and the first LineString point off the UI + * thread; then, on the UI thread, updates the placemark-name label and + * repositions the map/lat-lon inputs to that first point. + */ + private void loadKmzAsyncAndUpdateUi(@NonNull KmzFile kmz) { + final TextView nameView = tvKmzPlacemarkName; + if (nameView != null) nameView.setVisibility(View.GONE); + new Thread(() -> { + String name = kmz.getPlacemarkName(); + List points = kmz.getLinePoints(); + runOnUiThread(() -> { + currentKmzLine = points; + if (nameView != null) { + if (name != null && !name.isBlank()) { + nameView.setText(getString(R.string.Settings_KmzPlacemarkName, name)); + nameView.setVisibility(View.VISIBLE); + } else { + nameView.setVisibility(View.GONE); + } + } + if (!points.isEmpty()) { + KmzFile.Point first = points.get(0); + setLatLng(first.lat(), first.lon(), LOAD); + } + }); + }, "KmzReader").start(); + } + + @NonNull + private static String humanReadableSize(long bytes) { + if (bytes < 1024) return bytes + " B"; + int exp = (int) (Math.log(bytes) / Math.log(1024)); + char unit = "KMGTPE".charAt(exp - 1); + return String.format(Locale.ROOT, "%.2f %sB", bytes / Math.pow(1024, exp), unit); + } + } diff --git a/app/src/main/java/cl/coders/faketraveler/MockedLocationProvider.java b/app/src/main/java/cl/coders/faketraveler/MockedLocationProvider.java index 6f63df8..aa02156 100644 --- a/app/src/main/java/cl/coders/faketraveler/MockedLocationProvider.java +++ b/app/src/main/java/cl/coders/faketraveler/MockedLocationProvider.java @@ -63,29 +63,35 @@ private void startup(@NonNull LocationManager lm, int powerUsage, int accuracy, } /** - * Pushes the location in the system (mock). This is where the magic gets done. + * Pushes a mocked location into the system through this test provider. * - * @param lat latitude - * @param lon longitude + *

The bearing and speed from {@code source} are forwarded so + * navigation apps can display the correct movement direction and speed. + * Missing fields are populated with sensible defaults.

*/ - public void pushLocation(double lat, double lon) { + public void pushLocation(@NonNull Location source) { LocationManager lm = (LocationManager) ctx.getSystemService(Context.LOCATION_SERVICE); Location mockLocation = new Location(providerName); - mockLocation.setLatitude(lat); - mockLocation.setLongitude(lon); + mockLocation.setLatitude(source.getLatitude()); + mockLocation.setLongitude(source.getLongitude()); mockLocation.setAltitude(3F); mockLocation.setTime(System.currentTimeMillis()); mockLocation.setElapsedRealtimeNanos(SystemClock.elapsedRealtimeNanos()); - mockLocation.setSpeed(0.01F); - mockLocation.setBearing(1F); + mockLocation.setSpeed(source.hasSpeed() ? source.getSpeed() : 0.01F); + // A bearing of 0 is a legitimate value (due north), so only skip when + // the caller explicitly hasn't set one. + mockLocation.setBearing(source.hasBearing() ? source.getBearing() : 0F); mockLocation.setAccuracy(3F); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - mockLocation.setBearingAccuracyDegrees(0.1F); + mockLocation.setBearingAccuracyDegrees( + source.hasBearingAccuracy() ? source.getBearingAccuracyDegrees() : 0.1F); mockLocation.setVerticalAccuracyMeters(0.1F); - mockLocation.setSpeedAccuracyMetersPerSecond(0.01F); + mockLocation.setSpeedAccuracyMetersPerSecond( + source.hasSpeedAccuracy() ? source.getSpeedAccuracyMetersPerSecond() : 0.01F); } - Log.d(TAG, "pushLocation: " + lat + ", " + lon); + Log.d(TAG, "pushLocation: " + source.getLatitude() + ", " + source.getLongitude() + + " bearing=" + mockLocation.getBearing() + " speed=" + mockLocation.getSpeed()); lm.setTestProviderLocation(providerName, mockLocation); } diff --git a/app/src/main/java/cl/coders/faketraveler/MockedLocationService.java b/app/src/main/java/cl/coders/faketraveler/MockedLocationService.java index f515c8b..1c8a382 100644 --- a/app/src/main/java/cl/coders/faketraveler/MockedLocationService.java +++ b/app/src/main/java/cl/coders/faketraveler/MockedLocationService.java @@ -1,7 +1,12 @@ package cl.coders.faketraveler; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; import android.app.Service; import android.content.Intent; +import android.content.pm.ServiceInfo; import android.location.Location; import android.location.LocationManager; import android.os.Binder; @@ -10,6 +15,7 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.core.app.NotificationCompat; import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; @@ -26,6 +32,11 @@ public class MockedLocationService extends Service { @NonNull private static final String TAG = MockedLocationService.class.getSimpleName(); + /** Notification channel & id used to keep the service in the foreground. */ + @NonNull + private static final String CHANNEL_ID = "cl.coders.faketraveler.mocking"; + private static final int NOTIFICATION_ID = 1001; + @NonNull protected final MutableLiveData mockState = new MutableLiveData<>(); @NonNull @@ -42,21 +53,84 @@ public class MockedLocationService extends Service { @NonNull @Override public IBinder onBind(Intent intent) { + // Promote to foreground immediately: if the activity is destroyed + // while mocking is running, Android would otherwise stop us. + enterForeground(); indicateBinding(); return new MockedBinder(this); } + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Required within a few seconds when started via startForegroundService(...). + enterForeground(); + return START_NOT_STICKY; + } + @Override public boolean onUnbind(Intent intent) { - Log.d(TAG, "Mock is finished"); - for (TimerTask t : tasks) - t.cancel(); + Log.d(TAG, "Unbind (mocking continues if the service was also started)."); + // Do NOT tear down here: if the activity unbinds because it's going + // to the background, we want the mock to keep running. Teardown + // happens in onDestroy(), which fires when stopService(...) is + // called (Stop button) or after the last tick. + return super.onUnbind(intent); + } + + @Override + public void onDestroy() { + Log.d(TAG, "Service destroyed; stopping all mocks."); + for (TimerTask t : tasks) t.cancel(); tasks.clear(); - for (MockedLocationProvider prov : providers) - prov.shutdown(); + timer.cancel(); + for (MockedLocationProvider prov : providers) prov.shutdown(); providers.clear(); mockState.postValue(MockState.NOT_MOCKED); - return super.onUnbind(intent); + super.onDestroy(); + } + + private void enterForeground() { + try { + ensureNotificationChannel(); + Notification notification = buildNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION); + } else { + startForeground(NOTIFICATION_ID, notification); + } + } catch (Throwable t) { + Log.e(TAG, "Failed to enter foreground", t); + } + } + + private void ensureNotificationChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return; + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm == null || nm.getNotificationChannel(CHANNEL_ID) != null) return; + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + getString(R.string.MockingService_ChannelName), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(getString(R.string.MockingService_ChannelDesc)); + channel.setShowBadge(false); + nm.createNotificationChannel(channel); + } + + @NonNull + private Notification buildNotification() { + Intent open = new Intent(this, MainActivity.class); + open.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + PendingIntent pi = PendingIntent.getActivity(this, 0, open, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(getString(R.string.MockingService_Title)) + .setContentText(getString(R.string.MockingService_Text)) + .setOngoing(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setContentIntent(pi) + .build(); } private void indicateBinding() { @@ -65,12 +139,7 @@ private void indicateBinding() { protected void startMockedService(double longitude, double latitude, double longitudeDistance, double latitudeDistance, long mockMilli, int maxTime, float mockSpeed) { try { - providers.clear(); - providers.add(new MockedLocationProvider(LocationManager.GPS_PROVIDER, this)); - providers.add(new MockedLocationProvider(LocationManager.NETWORK_PROVIDER, this)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - providers.add(new MockedLocationProvider(LocationManager.FUSED_PROVIDER, this)); - } + createProviders(); MockedTask mockedTask = new MockedTask(longitude, latitude, longitudeDistance, latitudeDistance, maxTime, mockSpeed); timer.schedule(mockedTask, 0L, mockMilli); @@ -82,6 +151,66 @@ protected void startMockedService(double longitude, double latitude, double long } } + /** + * Publishes a sequence of pre-computed points along a route, one per + * {@code mockMilli} tick. Used for File-mode mocking (KMZ LineString). + */ + protected void startMockedRoute(@NonNull List points, long mockMilli, float mockSpeed) { + if (points.isEmpty()) { + mockState.postValue(MockState.MOCK_ERROR); + return; + } + try { + createProviders(); + + RouteTask routeTask = new RouteTask(points, mockSpeed); + timer.schedule(routeTask, 0L, mockMilli); + tasks.add(routeTask); + mockState.postValue(MockState.MOCKED); + } catch (SecurityException e) { + Log.e(TAG, "Could not construct mock location providers!", e); + mockState.postValue(MockState.MOCK_ERROR); + } + } + + private void createProviders() { + providers.clear(); + providers.add(new MockedLocationProvider(LocationManager.GPS_PROVIDER, this)); + providers.add(new MockedLocationProvider(LocationManager.NETWORK_PROVIDER, this)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + providers.add(new MockedLocationProvider(LocationManager.FUSED_PROVIDER, this)); + } + } + + private void applyCommonLocationFields(@NonNull Location value, float speed) { + if (speed > 0) { + value.setSpeed(speed); + value.setAccuracy(0.1f); + value.setTime(System.currentTimeMillis()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + value.setSpeedAccuracyMetersPerSecond(0.01f); + } + } + } + + /** + * Sets the bearing (compass heading) that navigation apps use to rotate + * their direction arrow. No-op when {@code fromLat/fromLon} and + * {@code toLat/toLon} are the same point (no direction of movement). + */ + private static void applyBearing(@NonNull Location value, + double fromLat, double fromLon, + double toLat, double toLon) { + if (fromLat == toLat && fromLon == toLon) return; + float bearing = KmzFile.bearingDegrees( + new KmzFile.Point(fromLon, fromLat, 0), + new KmzFile.Point(toLon, toLat, 0)); + value.setBearing(bearing); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + value.setBearingAccuracyDegrees(0.1f); + } + } + class MockedTask extends TimerTask { private final float speed; private double longitude; @@ -105,19 +234,14 @@ public void run() { Location value = new Location(LocationManager.GPS_PROVIDER); value.setLongitude(longitude); value.setLatitude(latitude); - if (speed > 0) { - value.setSpeed(speed); - value.setAccuracy(0.1f); - value.setTime(System.currentTimeMillis()); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - value.setSpeedAccuracyMetersPerSecond(0.01f); - } - } + applyCommonLocationFields(value, speed); + // Point movement: heading derived from the step deltas. + applyBearing(value, latitude, longitude, + latitude + latitudeMockedDistance, longitude + longitudeMockedDistance); mockedLocation.postValue(value); for (MockedLocationProvider prov : providers) - prov.pushLocation(latitude, longitude); + prov.pushLocation(value); ++currentTimes; if (maxLocationTimes != 0 && maxLocationTimes == currentTimes) { this.cancel(); @@ -129,6 +253,57 @@ public void run() { } } + /** + * Timer task that walks through a pre-computed list of geographic points, + * publishing one per tick until the list is exhausted. + */ + class RouteTask extends TimerTask { + @NonNull + private final List points; + private final float speed; + private int index = 0; + + RouteTask(@NonNull List points, float mockSpeed) { + this.points = points; + this.speed = mockSpeed; + } + + @Override + public void run() { + if (index >= points.size()) { + finish(); + return; + } + KmzFile.Point p = points.get(index++); + Location value = new Location(LocationManager.GPS_PROVIDER); + value.setLongitude(p.lon()); + value.setLatitude(p.lat()); + applyCommonLocationFields(value, speed); + // Heading toward the next route point; on the final point reuse + // the previous heading (going backwards to the point we came from + // would point the arrow the wrong way). + if (index < points.size()) { + KmzFile.Point next = points.get(index); + applyBearing(value, p.lat(), p.lon(), next.lat(), next.lon()); + } else if (index >= 2) { + KmzFile.Point prev = points.get(index - 2); + applyBearing(value, prev.lat(), prev.lon(), p.lat(), p.lon()); + } + + mockedLocation.postValue(value); + for (MockedLocationProvider prov : providers) + prov.pushLocation(value); + + if (index >= points.size()) finish(); + } + + private void finish() { + this.cancel(); + stopSelf(); + mockState.postValue(MockState.NOT_MOCKED); + } + } + public static class MockedBinder extends Binder { @NonNull private final MockedLocationService service; @@ -150,6 +325,10 @@ public void continueMock() { public void startMock(double longitude, double latitude, double longitudeDistance, double latitudeDistance, long mockMilli, int maxTimes, float mockSpeed) { service.startMockedService(longitude, latitude, longitudeDistance, latitudeDistance, mockMilli, maxTimes, mockSpeed); } + + public void startRoute(@NonNull List points, long mockMilli, float mockSpeed) { + service.startMockedRoute(points, mockMilli, mockSpeed); + } } } diff --git a/app/src/main/java/cl/coders/faketraveler/MoreActivity.java b/app/src/main/java/cl/coders/faketraveler/MoreActivity.java deleted file mode 100644 index 5055050..0000000 --- a/app/src/main/java/cl/coders/faketraveler/MoreActivity.java +++ /dev/null @@ -1,230 +0,0 @@ -package cl.coders.faketraveler; - -import static cl.coders.faketraveler.MainActivity.DECIMAL_FORMAT; -import static cl.coders.faketraveler.MainActivity.sharedPrefKey; -import static cl.coders.faketraveler.SharedPrefsUtil.getDouble; -import static cl.coders.faketraveler.SharedPrefsUtil.putDouble; - -import android.content.Context; -import android.content.SharedPreferences; -import android.os.Build; -import android.os.Bundle; -import android.text.Editable; -import android.text.Html; -import android.text.TextWatcher; -import android.text.method.LinkMovementMethod; -import android.util.Log; -import android.widget.CheckBox; -import android.widget.EditText; -import android.widget.TextView; - -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.graphics.Insets; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; - -import java.util.Locale; - -public class MoreActivity extends AppCompatActivity { - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_more); - - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.more_layout), (v, insets) -> { - Insets bars = insets.getInsets( - WindowInsetsCompat.Type.systemBars() - | WindowInsetsCompat.Type.displayCutout() - ); - v.setPadding(bars.left, bars.top, bars.right, bars.bottom); - return WindowInsetsCompat.CONSUMED; - }); - - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - - TextView tvLeafletLicense = findViewById(R.id.tv_LeafletLicense); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - tvLeafletLicense.setText(Html.fromHtml(getString(R.string.ActivityMore_LeafletLicense), - Html.FROM_HTML_MODE_LEGACY)); - } else { - tvLeafletLicense.setText(Html.fromHtml(getString(R.string.ActivityMore_LeafletLicense))); - } - tvLeafletLicense.setMovementMethod(LinkMovementMethod.getInstance()); - - EditText etDMockLat = findViewById(R.id.et_DMockLat); - etDMockLat.setText(DECIMAL_FORMAT.format(getDouble(sharedPref, "dLat", 0))); - etDMockLat.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) { - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (etDMockLat.getText().toString().isBlank()) { - putDouble(editor, "dLat", 0); - } else { - try { - putDouble(editor, "dLat", Double.parseDouble(etDMockLat.getText().toString())); - } catch (Throwable t) { - Log.e(MoreActivity.class.toString(), "Could not parse dLat!", t); - } - } - - editor.apply(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, - int before, int count) { - } - }); - - EditText etDMockLon = findViewById(R.id.et_DMockLon); - etDMockLon.setText(DECIMAL_FORMAT.format(getDouble(sharedPref, "dLng", 0))); - etDMockLon.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) { - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (etDMockLon.getText().toString().isBlank()) { - putDouble(editor, "dLng", 0); - } else { - try { - putDouble(editor, "dLng", Double.parseDouble(etDMockLon.getText().toString())); - } catch (Throwable t) { - Log.e(MoreActivity.class.toString(), "Could not parse dLng!", t); - } - } - - editor.apply(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, - int before, int count) { - } - }); - - EditText etMockCount = findViewById(R.id.et_MockCount); - etMockCount.setText(String.format(Locale.ROOT, "%d", sharedPref.getInt("mockCount", 0))); - etMockCount.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) { - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (etMockCount.getText().toString().isBlank()) { - editor.putInt("mockCount", 0); - } else { - try { - editor.putInt("mockCount", Integer.parseInt(etMockCount.getText().toString())); - } catch (Throwable t) { - Log.e(MoreActivity.class.toString(), "Could not parse mockCount!", t); - } - } - - editor.apply(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, - int before, int count) { - } - }); - - EditText etMockFrequency = findViewById(R.id.et_MockFrequency); - etMockFrequency.setText(String.format(Locale.ROOT, "%d", sharedPref.getInt("mockFrequency", 10))); - etMockFrequency.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) { - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (etMockFrequency.getText().toString().isBlank()) { - editor.putInt("mockFrequency", 10); - } else { - try { - editor.putInt("mockFrequency", Integer.parseInt(etMockFrequency.getText().toString())); - } catch (Throwable t) { - Log.e(MoreActivity.class.toString(), "Could not parse mockFrequency!", t); - } - } - - editor.apply(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - }); - - CheckBox mockSpeed = findViewById(R.id.cb_MockSpeed); - mockSpeed.setChecked(sharedPref.getBoolean("mockSpeed", true)); - mockSpeed.setOnCheckedChangeListener((compoundButton, b) -> { - Context context1 = getApplicationContext(); - SharedPreferences sharedPref1 = context1.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref1.edit(); - - editor.putBoolean("mockSpeed", mockSpeed.isChecked()); - - editor.apply(); - }); - - - EditText etMapProvider = findViewById(R.id.et_MapProvider); - etMapProvider.setText(sharedPref.getString("mapProvider", - MapProviderUtil.getDefaultMapProvider(Locale.getDefault()))); - etMapProvider.addTextChangedListener(new TextWatcher() { - @Override - public void afterTextChanged(Editable s) { - Context context = getApplicationContext(); - SharedPreferences sharedPref = context.getSharedPreferences(sharedPrefKey, Context.MODE_PRIVATE); - SharedPreferences.Editor editor = sharedPref.edit(); - - if (etMapProvider.getText().toString().isBlank()) { - editor.putString("mapProvider", MapProviderUtil.getDefaultMapProvider(Locale.getDefault())); - } else { - editor.putString("mapProvider", etMapProvider.getText().toString()); - } - - editor.apply(); - } - - @Override - public void beforeTextChanged(CharSequence s, int start, - int count, int after) { - } - - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) { - } - }); - } - -} diff --git a/app/src/main/res/drawable/ic_globe.xml b/app/src/main/res/drawable/ic_globe.xml new file mode 100644 index 0000000..8a34d79 --- /dev/null +++ b/app/src/main/res/drawable/ic_globe.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_search.xml b/app/src/main/res/drawable/ic_search.xml new file mode 100644 index 0000000..f2fb560 --- /dev/null +++ b/app/src/main/res/drawable/ic_search.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_settings.xml b/app/src/main/res/drawable/ic_settings.xml new file mode 100644 index 0000000..286b90b --- /dev/null +++ b/app/src/main/res/drawable/ic_settings.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0662c28..b727dc3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -8,64 +8,355 @@ android:orientation="vertical" tools:context=".MainActivity"> - + - - - - - - - - - - + + + + android:orientation="vertical"> + + + + + + + + + + + - + + - + + + + + + + + + + android:padding="14dp" + android:visibility="gone"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_height="wrap_content" + app:labelVisibilityMode="labeled" + app:menu="@menu/bottom_nav_menu" /> + diff --git a/app/src/main/res/layout/activity_more.xml b/app/src/main/res/layout/activity_more.xml deleted file mode 100644 index a1dc1f1..0000000 --- a/app/src/main/res/layout/activity_more.xml +++ /dev/null @@ -1,205 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/bottom_nav_menu.xml b/app/src/main/res/menu/bottom_nav_menu.xml new file mode 100644 index 0000000..dacb8e7 --- /dev/null +++ b/app/src/main/res/menu/bottom_nav_menu.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 268d801..27eea0e 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -17,20 +17,19 @@ Imiteret placeringstjeneste kører. Imitering stoppet. - - Hvordan bruger man det? - Hvor mange gange vil du imitere placeringen? (0 = uendelig) - Hvor mange sekunder mellem hver imitering? - Imiteret bevægelse som hastighed - Copyright 2018\-2025 Matías Castillo Felmer og bidragsydere - Source koden er tilgængelig på https://github.com/mcastillof/FakeTraveler - Rapportér bugs på https://github.com/mcastillof/FakeTraveler/issues - The launcher icon was created by @ArtistDev44 - thank you very much for your work! - Anvend et langt tryk, der hvor du vil placeres på kortet og tryk på Anvend-knappen. Placeringen vil blive imiteret én eller flere gange ifølge konfigurationen nedenfor. Imens imiteringen kører, kan du ændre placering med et langt tryk, der hvor du vil være. For at stoppe med at imitere placeringen, tryk på Stop-knappen. Note: Du skal aktivére Fake Traveler som imiteret lokationsapp i udvikler indstillingerne. - Imiterer bevægelse? (1 / 1 000 000°, 0 = nej) - Map Provider (e.g., \'OpenStreetMap\') - Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + Hvordan bruger man det? + Hvor mange gange vil du imitere placeringen? (0 = uendelig) + Hvor mange sekunder mellem hver imitering? + Imiteret bevægelse som hastighed + Copyright 2018\-2025 Matías Castillo Felmer og bidragsydere + Source koden er tilgængelig på https://github.com/mcastillof/FakeTraveler + Rapportér bugs på https://github.com/mcastillof/FakeTraveler/issues + The launcher icon was created by @ArtistDev44 - thank you very much for your work! + Anvend et langt tryk, der hvor du vil placeres på kortet og tryk på Anvend-knappen. Placeringen vil blive imiteret én eller flere gange ifølge konfigurationen nedenfor. Imens imiteringen kører, kan du ændre placering med et langt tryk, der hvor du vil være. For at stoppe med at imitere placeringen, tryk på Stop-knappen. Note: Du skal aktivére Fake Traveler som imiteret lokationsapp i udvikler indstillingerne. + Imiterer bevægelse? (1 / 1 000 000°, 0 = nej) + Map Provider (e.g., \'OpenStreetMap\') + Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2efae6f..bab61b1 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -17,19 +17,18 @@ Standort Simulation läuft. Standort Simulation beendet. - - Bedienungsanleitung - Wie oft möchten Sie den Standort simulieren? (0 = unbegrenzt) - Zeit zwischen jeder Simulation - Geschwindigkeit anhand von Bewegung simulieren - Copyright 2018\-2025 Matías Castillo Felmer and contributors - Quellcode verfügbar unter: https://github.com/mcastillof/FakeTraveler - Bugs melden unter: https://github.com/mcastillof/FakeTraveler/issues - Das Launcher-Icon wurde von @ArtistDev44 erstellt - vielen Dank für deine Arbeit! - Wählen Sie den gewünschten Standort durch langes Gedrückt-Halten auf der Karte aus. Klicken Sie auf "Anwenden". Der Standort wird je nach Einstellung ein- oder mehrmals simuliert. Während die Simulation läuft, können Sie jederzeit einen neuen Standort auswählen. Um die Simulation anzuhalten drücken Sie "Stopp". Hinweis: Um diese App nutzen zu können, müssen Sie sie als Simulierte-Standort-App in den Entwickleroptionen aktivieren. - Bewegung simulieren? (1 / 1 000 000°, 0 = nein) - Kartenanbieter (z. B. \'OpenStreetMap.DE\') - Benötigt Neustart - eine Liste der möglichen Werte finden Sie hier: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet als auch Leaflet-providers sind unter der BSD 2-Clause License lizenziert.]]> + Bedienungsanleitung + Wie oft möchten Sie den Standort simulieren? (0 = unbegrenzt) + Zeit zwischen jeder Simulation + Geschwindigkeit anhand von Bewegung simulieren + Copyright 2018\-2025 Matías Castillo Felmer and contributors + Quellcode verfügbar unter: https://github.com/mcastillof/FakeTraveler + Bugs melden unter: https://github.com/mcastillof/FakeTraveler/issues + Das Launcher-Icon wurde von @ArtistDev44 erstellt - vielen Dank für deine Arbeit! + Wählen Sie den gewünschten Standort durch langes Gedrückt-Halten auf der Karte aus. Klicken Sie auf "Anwenden". Der Standort wird je nach Einstellung ein- oder mehrmals simuliert. Während die Simulation läuft, können Sie jederzeit einen neuen Standort auswählen. Um die Simulation anzuhalten drücken Sie "Stopp". Hinweis: Um diese App nutzen zu können, müssen Sie sie als Simulierte-Standort-App in den Entwickleroptionen aktivieren. + Bewegung simulieren? (1 / 1 000 000°, 0 = nein) + Kartenanbieter (z. B. \'OpenStreetMap.DE\') + Benötigt Neustart - eine Liste der möglichen Werte finden Sie hier: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet als auch Leaflet-providers sind unter der BSD 2-Clause License lizenziert.]]> diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 743ca0f..6f0f671 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,19 +15,18 @@ Servicio de posiciones ficticias corriendo. Falsificaciones detenidas. - - ¿Cómo se usa? - ¿Cuántas veces quieres falsificar la ubicación? (0 = infinito) - ¿Cuántos segundos entre cada falsificación? - Mock movement as speed - Todos los derechos reservados 2018\-2025 Matías Castillo Felmer y contribuidores - Código fuente disponible en https://github.com/mcastillof/FakeTraveler - Reporte problemas en https://github.com/mcastillof/FakeTraveler/issues - The launcher icon was created by @ArtistDev44 - thank you very much for your work! - Mantenga pulsado en el mapa donde quiere la ubicación ficticia y luego presione el botón aplicar. La falsificación se aplicará una o más veces dependiendo de la configuración que sigue abajo. Mientras la falsificación esté operando, puedes cambiar de lugar tan solo manteniendo pulsado en el lugar que quieres estar. Presione el botón Detener para finalizar el engaño. Nota: debes activar Fake Traveler como aplicación de ubicación de prueba en Opciones del programador. - Mock movement? (1 / 1 000 000°, 0 = no) - Map Provider (e.g., \'OpenStreetMap\') - Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + ¿Cómo se usa? + ¿Cuántas veces quieres falsificar la ubicación? (0 = infinito) + ¿Cuántos segundos entre cada falsificación? + Mock movement as speed + Todos los derechos reservados 2018\-2025 Matías Castillo Felmer y contribuidores + Código fuente disponible en https://github.com/mcastillof/FakeTraveler + Reporte problemas en https://github.com/mcastillof/FakeTraveler/issues + The launcher icon was created by @ArtistDev44 - thank you very much for your work! + Mantenga pulsado en el mapa donde quiere la ubicación ficticia y luego presione el botón aplicar. La falsificación se aplicará una o más veces dependiendo de la configuración que sigue abajo. Mientras la falsificación esté operando, puedes cambiar de lugar tan solo manteniendo pulsado en el lugar que quieres estar. Presione el botón Detener para finalizar el engaño. Nota: debes activar Fake Traveler como aplicación de ubicación de prueba en Opciones del programador. + Mock movement? (1 / 1 000 000°, 0 = no) + Map Provider (e.g., \'OpenStreetMap\') + Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a012466..75ff6ff 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -16,20 +16,19 @@ Mudanças desativadas. - - Como usar? - Quantas vezes deseja mudar sua localização? (0 = infinito) - Quantos segundos entre cada mudança? - Mock movement as speed - Todos os direitos reservados 2018\-2025 Matías Castillo Felmer e contribuidores - Código fonte disponível em https://github.com/mcastillof/FakeTraveler - Notifique problemas em https://github.com/mcastillof/FakeTraveler/issues - The launcher icon was created by @ArtistDev44 - thank you very much for your work! - Pressione no mapa o local onde deseja a configuração fictícia e então aperte o botão aplicar. A configuração se aplicará uma ou mais vezes e dependerá das configurações abaixo. Enquanto a aplicação estiver ativa, poderá mudar de lugar ao apertar no local desejado no mapa. Presione o botão parar para finalizar e mudança. Nota: Sempre ativar o Fake Traveler como aplicação para app de local fictício nas configurações do sistema, modo programador. - Mock movement? (1 / 1 000 000°, 0 = no) - Map Provider (e.g., \'OpenStreetMap\') - Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + Como usar? + Quantas vezes deseja mudar sua localização? (0 = infinito) + Quantos segundos entre cada mudança? + Mock movement as speed + Todos os direitos reservados 2018\-2025 Matías Castillo Felmer e contribuidores + Código fonte disponível em https://github.com/mcastillof/FakeTraveler + Notifique problemas em https://github.com/mcastillof/FakeTraveler/issues + The launcher icon was created by @ArtistDev44 - thank you very much for your work! + Pressione no mapa o local onde deseja a configuração fictícia e então aperte o botão aplicar. A configuração se aplicará uma ou mais vezes e dependerá das configurações abaixo. Enquanto a aplicação estiver ativa, poderá mudar de lugar ao apertar no local desejado no mapa. Presione o botão parar para finalizar e mudança. Nota: Sempre ativar o Fake Traveler como aplicação para app de local fictício nas configurações do sistema, modo programador. + Mock movement? (1 / 1 000 000°, 0 = no) + Map Provider (e.g., \'OpenStreetMap\') + Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index b37ee56..354b95a 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -16,19 +16,18 @@ Служба размещения местоположения работает. Изменение остановлено. - - Как это использовать? - Сколько раз вы хотите изменять локацию? (0 = бесконечно) - Сколько секунд между каждым изменением? - Mock movement as speed - Copyright 2018\-2025 Матиас Кастильо Фелмер и авторы - Исходный код доступен на https://github.com/mcastillof/FakeTraveler. - Сообщить об ошибках на https://github.com/mcastillof/FakeTraveler/issues - The launcher icon was created by @ArtistDev44 - thank you very much for your work! - Длительное нажатие, где вы хотите быть на карте и нажмите кнопку Применить. Место будет проверено один или несколько раз в соответствии с приведенной ниже конфигурацией. Пока изменение работает, вы можете изменить местоположение, просто нажав и удерживая его там, где вы находитесь. Чтобы прекратить издеваться над местоположением, нажмите кнопку Стоп. Примечание: вы должны включить Fake Traveler в качестве приложения с фиктивным местоположением в опциях для разработчиков. - Mock movement? (1 / 1 000 000°, 0 = no) - Map Provider (e.g., \'OpenStreetMap\') - Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + Как это использовать? + Сколько раз вы хотите изменять локацию? (0 = бесконечно) + Сколько секунд между каждым изменением? + Mock movement as speed + Copyright 2018\-2025 Матиас Кастильо Фелмер и авторы + Исходный код доступен на https://github.com/mcastillof/FakeTraveler. + Сообщить об ошибках на https://github.com/mcastillof/FakeTraveler/issues + The launcher icon was created by @ArtistDev44 - thank you very much for your work! + Длительное нажатие, где вы хотите быть на карте и нажмите кнопку Применить. Место будет проверено один или несколько раз в соответствии с приведенной ниже конфигурацией. Пока изменение работает, вы можете изменить местоположение, просто нажав и удерживая его там, где вы находитесь. Чтобы прекратить издеваться над местоположением, нажмите кнопку Стоп. Примечание: вы должны включить Fake Traveler в качестве приложения с фиктивным местоположением в опциях для разработчиков. + Mock movement? (1 / 1 000 000°, 0 = no) + Map Provider (e.g., \'OpenStreetMap\') + Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> diff --git a/app/src/main/res/values-v35/styles.xml b/app/src/main/res/values-v35/styles.xml index e7a8f86..e60cc45 100644 --- a/app/src/main/res/values-v35/styles.xml +++ b/app/src/main/res/values-v35/styles.xml @@ -1,7 +1,7 @@ - diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 5ad97a0..5456e03 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -18,18 +18,17 @@ 模拟位置服务运行中。 模拟已停止 - - 如何使用它? - 您想模拟该位置多少次? (0 =无限) - 每个模拟之间有几秒钟间隔? - Mock movement as speed - 版权2018 \ -2025MatíasCastillo Felmer和贡献者 - 源代码在https://github.com/mcastillof/faketraveler中可用 - 将错误报告到https://github.com/mcastillof/faketraveler/issues - 启动器图标是由 @ArtistDev44创建的 - 非常感谢您的工作! - 长按您要位于地图中的位置,然后按“应用”按钮。该位置将相应地模拟一次或多次以在下面的配置。当模拟运行时,您可以通过长时间按照自己的位置来更改位置。要停止模拟位置,请按“停止”按钮。注意:您必须在开发人员选项中启用虚拟旅者为模拟位置应用程序。 - 模拟运动? (1/1 000 000°,0 =否) - 地图提供商(例如,\'openstreetMap \') - 需要重新启动 - 可以在此处找到可能的值列表:https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + 如何使用它? + 您想模拟该位置多少次? (0 =无限) + 每个模拟之间有几秒钟间隔? + Mock movement as speed + 版权2018 \ -2025MatíasCastillo Felmer和贡献者 + 源代码在https://github.com/mcastillof/faketraveler中可用 + 将错误报告到https://github.com/mcastillof/faketraveler/issues + 启动器图标是由 @ArtistDev44创建的 - 非常感谢您的工作! + 长按您要位于地图中的位置,然后按“应用”按钮。该位置将相应地模拟一次或多次以在下面的配置。当模拟运行时,您可以通过长时间按照自己的位置来更改位置。要停止模拟位置,请按“停止”按钮。注意:您必须在开发人员选项中启用虚拟旅者为模拟位置应用程序。 + 模拟运动? (1/1 000 000°,0 =否) + 地图提供商(例如,\'openstreetMap \') + 需要重新启动 - 可以在此处找到可能的值列表:https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 48bee3d..d97bb4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9,6 +9,13 @@ Apply Stop + Search an address: Street, City + Search + + + Search + Lat/Lon + Settings No latitude/longitude values. Long press in the map where you want to be located. @@ -17,18 +24,30 @@ Mocking location service running. Mocking stopped. - - How to use it? - How many times you want to mock the location? (0 = infinite) - How many seconds between every mock? - Mock movement as speed - Copyright 2018\-2025 Matías Castillo Felmer and contributors - Source code available in https://github.com/mcastillof/FakeTraveler - Report bugs to https://github.com/mcastillof/FakeTraveler/issues - The launcher icon was created by @ArtistDev44 - thank you very much for your work! - Long press where you want to be located in the map and press Apply button. The location will be mocked one or more times accordingly to configuration below. While the mock is running, you can change the location just by long pressing where you want to be. To stop mocking the location, press the Stop button. Note: you must enable Fake Traveler as mock location app in Developers options. - Mock movement? (1 / 1 000 000°, 0 = no) - Map Provider (e.g., \'OpenStreetMap\') - Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ - Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> + Location mocking + Ongoing notification while a mocked location is being published. + Fake Traveler + Mocking location… + + How to use it? + Mode + Point + File + Open KMZ file + File size: %1$s + Placemark: %1$s + Movement speed (km/h) + Seconds before start (hold on the starting point, e.g. to switch to your navigation app) + How many times you want to mock the location? (0 = infinite) + How many seconds between every mock? + Mock movement as speed + Copyright 2018\-2025 Matías Castillo Felmer and contributors + Source code available in https://github.com/mcastillof/FakeTraveler + Report bugs to https://github.com/mcastillof/FakeTraveler/issues + The launcher icon was created by @ArtistDev44 - thank you very much for your work! + Long press where you want to be located in the map and press Apply button. The location will be mocked one or more times accordingly to configuration below. While the mock is running, you can change the location just by long pressing where you want to be. To stop mocking the location, press the Stop button. Note: you must enable Fake Traveler as mock location app in Developers options. + Mock movement? (1 / 1 000 000°, 0 = no) + Map Provider (e.g., \'OpenStreetMap\') + Requires restart - a list of possible values can be found here: https://leaflet-extras.github.io/leaflet-providers/preview/ + Leaflet and Leaflet-providers are licensed under the BSD 2-Clause License.]]> diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index e7a8f86..e60cc45 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,7 +1,7 @@ - diff --git a/app/src/test/java/cl/coders/faketraveler/KmzFileTest.java b/app/src/test/java/cl/coders/faketraveler/KmzFileTest.java new file mode 100644 index 0000000..690394e --- /dev/null +++ b/app/src/test/java/cl/coders/faketraveler/KmzFileTest.java @@ -0,0 +1,279 @@ +package cl.coders.faketraveler; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.net.Uri; +import android.provider.OpenableColumns; + +import org.junit.Before; +import org.junit.Test; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List;/** + * Unit tests for {@link KmzFile}. + * + *

The test loads a real KMZ file bundled under + * {@code app/src/test/resources}, uses its byte length as the expected size, + * and mocks the Android {@link ContentResolver} to report that size for + * {@link OpenableColumns#SIZE}. It then verifies that + * {@link KmzFile#getSize()} returns the same value.

+ */ +public class KmzFileTest { + + /** KMZ file bundled under {@code app/src/test/resources}. */ + private static final String EXAMPLE_KMZ_RESOURCE = "/Zavicaj-Mandarina.kmz"; + + private Context context; + private ContentResolver resolver; + private Uri uri; + private long exampleKmzSize; + + @Before + public void setUp() throws Exception { + exampleKmzSize = loadExampleKmzSize(); + + context = mock(Context.class); + Context appContext = mock(Context.class); + resolver = mock(ContentResolver.class); + uri = mock(Uri.class); + + when(context.getApplicationContext()).thenReturn(appContext); + when(appContext.getContentResolver()).thenReturn(resolver); + } + + @Test + public void getSize_returnsSizeReportedByContentResolver() { + assertTrue("Example KMZ must be non-empty", exampleKmzSize > 0); + + Cursor cursor = mock(Cursor.class); + when(resolver.query(any(), any(), any(), any(), any())).thenReturn(cursor); + when(cursor.moveToFirst()).thenReturn(true); + when(cursor.getColumnIndex(OpenableColumns.SIZE)).thenReturn(0); + when(cursor.isNull(0)).thenReturn(false); + when(cursor.getLong(0)).thenReturn(exampleKmzSize); + + KmzFile kmz = new KmzFile(context, uri); + + assertEquals(exampleKmzSize, kmz.getSize()); + } + + @Test + public void getSize_returnsMinusOneWhenQueryFails() { + when(resolver.query(any(), any(), any(), any(), any())).thenReturn(null); + + KmzFile kmz = new KmzFile(context, uri); + + assertEquals(-1, kmz.getSize()); + } + + @Test + public void getSize_returnsMinusOneWhenSizeColumnMissing() { + Cursor cursor = mock(Cursor.class); + when(resolver.query(any(), any(), any(), any(), any())).thenReturn(cursor); + when(cursor.moveToFirst()).thenReturn(true); + when(cursor.getColumnIndex(OpenableColumns.SIZE)).thenReturn(-1); + + KmzFile kmz = new KmzFile(context, uri); + + assertEquals(-1, kmz.getSize()); + } + + @Test + public void getPlacemarkName_returnsNameFromKml() throws Exception { + // Serve the real KMZ bytes when the class opens the URI. + when(resolver.openInputStream(any())).thenAnswer( + inv -> getClass().getResourceAsStream(EXAMPLE_KMZ_RESOURCE)); + + KmzFile kmz = new KmzFile(context, uri); + + assertEquals("Zavi\u010daj - Mandarina", kmz.getPlacemarkName()); + } + + @Test + public void getPlacemarkName_returnsNullWhenStreamCannotBeOpened() throws Exception { + when(resolver.openInputStream(any())).thenReturn(null); + + KmzFile kmz = new KmzFile(context, uri); + + assertNull(kmz.getPlacemarkName()); + } + + @Test + public void getLinePoints_returnsCoordinatesFromLineString() throws Exception { + // Serve the real KMZ bytes each time the class opens the URI. + when(resolver.openInputStream(any())).thenAnswer( + inv -> getClass().getResourceAsStream(EXAMPLE_KMZ_RESOURCE)); + + KmzFile kmz = new KmzFile(context, uri); + List points = kmz.getLinePoints(); + + assertNotNull(points); + assertFalse("Expected at least one point", points.isEmpty()); + + // First point in the LineString of the example file. + KmzFile.Point first = points.get(0); + assertEquals(20.460511, first.lon(), 1e-9); + assertEquals(44.793898, first.lat(), 1e-9); + assertEquals(0d, first.alt(), 1e-9); + + // A known point from the middle of the LineString snippet. + assertTrue("Expected a specific mid-line point to be present", + points.contains(new KmzFile.Point(20.467617, 44.790755, 0d))); + + // Every point should have plausible WGS84 lon/lat values. + for (KmzFile.Point p : points) { + assertTrue("lon out of range: " + p.lon(), p.lon() >= -180 && p.lon() <= 180); + assertTrue("lat out of range: " + p.lat(), p.lat() >= -90 && p.lat() <= 90); + } + } + + @Test + public void getLinePoints_returnsEmptyWhenStreamCannotBeOpened() throws Exception { + when(resolver.openInputStream(any())).thenReturn(null); + + KmzFile kmz = new KmzFile(context, uri); + + assertTrue(kmz.getLinePoints().isEmpty()); + } + + @Test + public void sampleAlongLine_walksStraightLineAtGivenSpeed() { + // 1 km east along the equator, using the same Earth radius that + // KmzFile uses for the haversine distance. + final double kmPerDegreeAtEquator = Math.toRadians(1) * KmzFile.EARTH_RADIUS_KM; + final double dLon = 1d / kmPerDegreeAtEquator; + List line = java.util.Arrays.asList( + new KmzFile.Point(0d, 0d, 0d), + new KmzFile.Point(dLon, 0d, 0d)); + + // 60 km/h, sampling every 60 s → 1 km per step → straight to endpoint. + // First point is always emitted at t=0. + List samples = KmzFile.sampleAlongLine(line, 60d, 60d); + + assertEquals(2, samples.size()); + assertEquals(0d, samples.get(0).lon(), 1e-9); + assertEquals(0d, samples.get(0).lat(), 1e-9); + assertEquals(dLon, samples.get(1).lon(), 1e-9); + assertEquals(0d, samples.get(1).lat(), 1e-9); + } + + @Test + public void sampleAlongLine_producesRegularlySpacedInterpolatedPoints() { + // Straight ~10 km segment along the equator, using the same Earth + // radius that KmzFile uses for the haversine distance. + final double kmPerDegreeAtEquator = Math.toRadians(1) * KmzFile.EARTH_RADIUS_KM; + final double totalKm = 10d; + final double oneKmDeg = 1d / kmPerDegreeAtEquator; + final double dLon = totalKm * oneKmDeg; + List line = java.util.Arrays.asList( + new KmzFile.Point(0d, 0d, 0d), + new KmzFile.Point(dLon, 0d, 0d)); + + // 60 km/h, every 60 s → 1 km per step → expect first + 10 more = 11 points. + List samples = KmzFile.sampleAlongLine(line, 60d, 60d); + assertEquals(11, samples.size()); + + // Consecutive samples must be spaced ~1 km apart in longitude. + for (int i = 1; i < samples.size(); i++) { + double step = samples.get(i).lon() - samples.get(i - 1).lon(); + assertEquals(oneKmDeg, step, 1e-9); + } + // Last point is exactly the endpoint. + assertEquals(dLon, samples.get(samples.size() - 1).lon(), 1e-9); + } + + @Test + public void sampleAlongLine_returnsFirstPointForZeroSpeedOrInterval() { + List line = java.util.Arrays.asList( + new KmzFile.Point(1d, 2d, 0d), + new KmzFile.Point(3d, 4d, 0d)); + + assertEquals(1, KmzFile.sampleAlongLine(line, 0d, 60d).size()); + assertEquals(1, KmzFile.sampleAlongLine(line, 60d, 0d).size()); + assertEquals(line.get(0), KmzFile.sampleAlongLine(line, 0d, 0d).get(0)); + } + + @Test + public void sampleAlongLine_returnsEmptyForEmptyLine() { + assertTrue(KmzFile.sampleAlongLine(java.util.Collections.emptyList(), 60d, 60d).isEmpty()); + } + + @Test + public void bearingDegrees_cardinalDirections() { + KmzFile.Point origin = new KmzFile.Point(0d, 0d, 0d); + assertEquals(0f, KmzFile.bearingDegrees(origin, new KmzFile.Point(0d, 1d, 0d)), 1e-3f); // N + assertEquals(90f, KmzFile.bearingDegrees(origin, new KmzFile.Point(1d, 0d, 0d)), 1e-3f); // E + assertEquals(180f, KmzFile.bearingDegrees(origin, new KmzFile.Point(0d, -1d, 0d)), 1e-3f); // S + assertEquals(270f, KmzFile.bearingDegrees(origin, new KmzFile.Point(-1d, 0d, 0d)), 1e-3f); // W + } + + @Test + public void bearingDegrees_returnsZeroForSamePoint() { + KmzFile.Point p = new KmzFile.Point(10d, 20d, 0d); + assertEquals(0f, KmzFile.bearingDegrees(p, p), 0f); + } + + @Test + public void samplePoints_producesFirstAndLastPointsOfKmzLineString() throws Exception { + when(resolver.openInputStream(any())).thenAnswer( + inv -> getClass().getResourceAsStream(EXAMPLE_KMZ_RESOURCE)); + + KmzFile kmz = new KmzFile(context, uri); + List line = kmz.getLinePoints(); + List samples = kmz.samplePoints(60d, 10d); + + assertFalse(samples.isEmpty()); + // Always starts at the first LineString point. + assertEquals(line.get(0).lon(), samples.get(0).lon(), 1e-9); + assertEquals(line.get(0).lat(), samples.get(0).lat(), 1e-9); + // Always ends at the last LineString point. + KmzFile.Point lastLine = line.get(line.size() - 1); + KmzFile.Point lastSample = samples.get(samples.size() - 1); + assertEquals(lastLine.lon(), lastSample.lon(), 1e-9); + assertEquals(lastLine.lat(), lastSample.lat(), 1e-9); + } + + /** + * Loads the bundled example KMZ from the test classpath and returns its + * length in bytes. + */ + private long loadExampleKmzSize() throws Exception { + URL resource = getClass().getResource(EXAMPLE_KMZ_RESOURCE); + assertNotNull("Missing test resource: " + EXAMPLE_KMZ_RESOURCE, resource); + // Prefer a direct filesystem read when possible, so we get the exact + // on-disk byte length without buffering the whole file in memory. + if ("file".equals(resource.getProtocol())) { + return Files.size(Paths.get(resource.toURI())); + } + try (InputStream in = resource.openStream(); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + return out.size(); + } + } +} + + + + + + + + + diff --git a/app/src/test/resources/README.md b/app/src/test/resources/README.md new file mode 100644 index 0000000..76142ac --- /dev/null +++ b/app/src/test/resources/README.md @@ -0,0 +1,8 @@ +# Test resources + +`Zavicaj-Mandarina.kmz` is a real KMZ file used by `KmzFileTest` to verify +that `KmzFile#getSize()` returns the size reported by the content resolver. +The test loads the file from the classpath and uses its byte length as the +expected size. + + diff --git a/app/src/test/resources/Zavicaj-Mandarina.kmz b/app/src/test/resources/Zavicaj-Mandarina.kmz new file mode 100644 index 0000000..403d1c7 Binary files /dev/null and b/app/src/test/resources/Zavicaj-Mandarina.kmz differ