Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
03b6f35
Add Android project settings
Golradreth Jul 20, 2026
7e7f9c7
Add Android root build file
Golradreth Jul 20, 2026
ffb14ae
Configure Android app module
Golradreth Jul 20, 2026
8b8420c
Add ProGuard configuration
Golradreth Jul 20, 2026
cbfb84d
Add Android manifest and permissions
Golradreth Jul 20, 2026
898ebc8
Implement multi-route offline hiking app
Golradreth Jul 20, 2026
ff175bb
Add foreground GPS tracking service
Golradreth Jul 20, 2026
ff5bc26
Add APK build workflow with bundled official GPX
Golradreth Jul 20, 2026
d1252e7
Trigger Android APK build
Golradreth Jul 20, 2026
f5c2c77
Use default-branch APK workflow
Golradreth Jul 20, 2026
644a068
Add interactive IGN and OSM map dependency
Golradreth Jul 20, 2026
cba7c3b
Enable online map tiles and network state
Golradreth Jul 20, 2026
0bb0db9
Fix persistent GPS tracking and route progress
Golradreth Jul 20, 2026
7cab6cc
Add robust GPX repository and segment based route math
Golradreth Jul 20, 2026
d23342d
Replace placeholder drawing with interactive IGN and OSM map
Golradreth Jul 20, 2026
708b655
Trigger verified v3 build
Golradreth Jul 20, 2026
6186452
Refresh v3 build trigger
Golradreth Jul 20, 2026
6d73bbf
Fix osmdroid API packages and map configuration
Golradreth Jul 20, 2026
dac96cc
Document legacy receiver compatibility for Android lint
Golradreth Jul 20, 2026
bc5cd4e
Redesign full-screen map UI and add directional location arrow
Golradreth Jul 20, 2026
6fa0874
Bump redesigned app to version 4.0
Golradreth Jul 20, 2026
f1db302
Remove white GPX route outline
build-bot Jul 20, 2026
3d8e850
No-op refresh before OSM build
Golradreth Jul 20, 2026
a4a1a77
Trigger OpenStreetMap v4.2 build
Golradreth Jul 20, 2026
2189752
Trigger simplified OpenStreetMap v4.2 build
Golradreth Jul 20, 2026
96136a9
Add v4.2 Android build workflow to app branch
Golradreth Jul 20, 2026
cb6aef4
Trigger v4.2 Android build
Golradreth Jul 20, 2026
8f63818
Remove unapproved workflow from app branch
Golradreth Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nivolet-app-v2/BUILD_TRIGGER.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Build requested after enabling the workflow on the default branch.
2 changes: 2 additions & 0 deletions nivolet-app-v2/BUILD_TRIGGER_V3.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Build Rando Savoie v3 with interactive IGN and OSM maps.
Run 2.
34 changes: 34 additions & 0 deletions nivolet-app-v2/app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
plugins {
id 'com.android.application'
}

android {
namespace 'com.golradreth.randooffline'
compileSdk 35

defaultConfig {
applicationId 'com.golradreth.randooffline'
minSdk 26
targetSdk 35
versionCode 5
versionName '4.1'
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}

compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}

dependencies {
implementation 'org.osmdroid:osmdroid-android:6.1.20'
}

// Build trigger: OpenStreetMap tile-policy fix v4.2.
8 changes: 8 additions & 0 deletions nivolet-app-v2/app/lint.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<lint>
<!-- Android 13+ utilise explicitement RECEIVER_NOT_EXPORTED dans MainActivity.
L'ancienne surcharge sans drapeau reste nécessaire sur Android 8 à 12. -->
<issue id="UnspecifiedRegisterReceiverFlag">
<ignore path="src/main/java/com/golradreth/randooffline/MainActivity.java" />
</issue>
</lint>
1 change: 1 addition & 0 deletions nivolet-app-v2/app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Aucune règle spécifique nécessaire pour cette application personnelle.
35 changes: 35 additions & 0 deletions nivolet-app-v2/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-feature android:name="android.hardware.location.gps" android:required="false" />

<application
android:allowBackup="false"
android:label="Rando Savoie"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light.NoActionBar">

<activity
android:name=".MainActivity"
android:exported="true"
android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>

<service
android:name=".HikeService"
android:exported="false"
android:foregroundServiceType="location" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package com.golradreth.randooffline;

import android.Manifest;
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.SharedPreferences;
import android.content.pm.PackageManager;
import android.location.Location;
import android.location.LocationListener;
import android.location.LocationManager;
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
import android.os.VibrationEffect;
import android.os.Vibrator;

public class HikeService extends Service implements LocationListener {
static final String ACTION_LOCATION = "com.golradreth.randooffline.LOCATION";
static final String EXTRA_LOCATION = "location";
static final String EXTRA_WALKED = "walked";
static final String EXTRA_ELAPSED = "elapsed";
static final String EXTRA_ROUTE_ID = "route_id";

static final String SESSION_PREFS = "tracking_session";
static final String SESSION_TRACKING = "tracking";

private static final String CHANNEL_ID = "hike_tracking";
private static final int NOTIFICATION_ID = 73;
private static final double OFF_ROUTE_WARNING_METERS = 80;

private LocationManager locationManager;
private Location previousLocation;
private Route activeRoute;
private double walkedMeters;
private long startedAt;
private long lastWarningAt;
private int lastRouteIndex;
private boolean updatesRequested;

@Override
public void onCreate() {
super.onCreate();
createNotificationChannel();
startedAt = System.currentTimeMillis();
setTrackingState(true);
startForeground(NOTIFICATION_ID, notification("Initialisation du GPS…"));
locationManager = (LocationManager) getSystemService(LOCATION_SERVICE);
}

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
RouteRepository repository = new RouteRepository(this);
String routeId = intent == null ? null : intent.getStringExtra(EXTRA_ROUTE_ID);
if (routeId == null) routeId = repository.getActiveRouteId();
activeRoute = repository.loadRoute(routeId);
if (activeRoute == null) activeRoute = repository.loadActiveRoute();

if (!updatesRequested) requestLocationUpdates();
return START_STICKY;
}

private void requestLocationUpdates() {
if (checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)
!= PackageManager.PERMISSION_GRANTED) {
updateNotification("Autorisation GPS manquante");
stopSelf();
return;
}

try {
boolean providerAvailable = false;
if (locationManager.isProviderEnabled(LocationManager.GPS_PROVIDER)) {
locationManager.requestLocationUpdates(
LocationManager.GPS_PROVIDER,
3000L,
3f,
this
);
providerAvailable = true;
}
if (locationManager.isProviderEnabled(LocationManager.NETWORK_PROVIDER)) {
locationManager.requestLocationUpdates(
LocationManager.NETWORK_PROVIDER,
6000L,
8f,
this
);
providerAvailable = true;
}

updatesRequested = providerAvailable;
if (!providerAvailable) {
updateNotification("Active la localisation dans les réglages");
} else {
updateNotification("Recherche du signal GPS…");
}
} catch (SecurityException error) {
updateNotification("Le suivi GPS a été bloqué");
stopSelf();
}
}

@Override
public void onLocationChanged(Location location) {
if (location == null) return;

if (previousLocation != null
&& previousLocation.getAccuracy() <= 60
&& location.getAccuracy() <= 60) {
float step = previousLocation.distanceTo(location);
long elapsed = Math.max(1L, location.getTime() - previousLocation.getTime());
float speed = step / (elapsed / 1000f);

if (step >= 1f && step <= 250f && speed <= 12f) {
walkedMeters += step;
}
}
previousLocation = location;

RouteProgress progress = activeRoute == null
? new RouteProgress(0, Double.NaN, Double.NaN)
: RouteMath.progress(
location,
activeRoute,
Math.max(0, lastRouteIndex - 30)
);
lastRouteIndex = Math.max(lastRouteIndex, progress.index);

if (!Double.isNaN(progress.offRouteMeters)
&& progress.offRouteMeters > OFF_ROUTE_WARNING_METERS) {
long now = System.currentTimeMillis();
if (now - lastWarningAt > 90_000L) {
Vibrator vibrator = (Vibrator) getSystemService(VIBRATOR_SERVICE);
if (vibrator != null && vibrator.hasVibrator()) {
vibrator.vibrate(VibrationEffect.createOneShot(700, 200));
}
lastWarningAt = now;
}
}

String message;
if (Double.isNaN(progress.offRouteMeters)) {
message = "GPS actif • " + RouteMath.formatDistance(walkedMeters) + " parcourus";
} else {
message = "Écart " + RouteMath.formatDistance(progress.offRouteMeters)
+ " • Reste " + RouteMath.formatDistance(progress.remainingMeters);
}
updateNotification(message);

Intent broadcast = new Intent(ACTION_LOCATION);
broadcast.setPackage(getPackageName());
broadcast.putExtra(EXTRA_LOCATION, location);
broadcast.putExtra(EXTRA_WALKED, walkedMeters);
broadcast.putExtra(EXTRA_ELAPSED, System.currentTimeMillis() - startedAt);
sendBroadcast(broadcast);
}

private Notification notification(String message) {
Intent openApp = new Intent(this, MainActivity.class);
PendingIntent pendingIntent = PendingIntent.getActivity(
this,
0,
openApp,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);

return new Notification.Builder(this, CHANNEL_ID)
.setContentTitle("Suivi randonnée actif")
.setContentText(message)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.build();
}

private void updateNotification(String message) {
NotificationManager manager = (NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (manager != null) manager.notify(NOTIFICATION_ID, notification(message));
}

private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= 26) {
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
"Suivi de randonnée",
NotificationManager.IMPORTANCE_LOW
);
channel.setDescription("Position GPS et alerte d'écart au tracé");
NotificationManager manager =
(NotificationManager) getSystemService(NOTIFICATION_SERVICE);
if (manager != null) manager.createNotificationChannel(channel);
}
}

private void setTrackingState(boolean tracking) {
SharedPreferences prefs = getSharedPreferences(SESSION_PREFS, MODE_PRIVATE);
prefs.edit().putBoolean(SESSION_TRACKING, tracking).apply();
}

@Override
public void onProviderEnabled(String provider) {
if (!updatesRequested) requestLocationUpdates();
}

@Override
public void onProviderDisabled(String provider) {
updateNotification("Signal GPS indisponible");
}

@Override
@Deprecated
public void onStatusChanged(String provider, int status, Bundle extras) {
}

@Override
public void onDestroy() {
if (locationManager != null) locationManager.removeUpdates(this);
setTrackingState(false);
super.onDestroy();
}

@Override
public IBinder onBind(Intent intent) {
return null;
}
}
Loading
Loading