Add PWA and push notification support for mobile app deployment
Major features added: - Progressive Web App (PWA) manifest and service worker for offline support - Push notifications with VAPID authentication - Mobile-optimized UI with touch navigation fix - Admin panel with configurable settings - Geocache sound alerts - App icons in all required sizes Technical improvements: - Fixed mobile touch handling for navigation selection - Added remesh tool for track point standardization - Improved pathfinding algorithm for more efficient routes - WebSocket-based real-time multi-user tracking - Docker deployment with persistent data volumes Ready for APK generation via PWA2APK.com or Bubblewrap Full offline support with map tile caching Push notifications for geocache alerts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>master
-
9.claude/settings.local.json
-
6.gitignore
-
10.well-known/assetlinks.json
-
121BUILD_APK_INSTRUCTIONS.md
-
12Dockerfile
-
13generate-vapid-keys.js
-
82generate_icons.py
-
60geocaches.json
-
BINicon-128x128.png
-
BINicon-144x144.png
-
BINicon-152x152.png
-
BINicon-192x192.png
-
BINicon-384x384.png
-
BINicon-512x512.png
-
BINicon-72x72.png
-
BINicon-96x96.png
-
694index.html
-
94manifest.json
-
4001package-lock.json
-
5package.json
-
148server.js
-
214service-worker.js
-
54twa-manifest.json
@ -0,0 +1,6 @@ |
|||
node_modules/ |
|||
.env |
|||
.claude/ |
|||
push-subscriptions.json |
|||
*.backup.* |
|||
.DS_Store |
|||
@ -0,0 +1,10 @@ |
|||
[{ |
|||
"relation": ["delegate_permission/common.handle_all_urls"], |
|||
"target": { |
|||
"namespace": "android_app", |
|||
"package_name": "org.duckdns.bibbit.hikemap", |
|||
"sha256_cert_fingerprints": [ |
|||
"4B:99:EB:12:8C:5C:7B:9B:3C:2F:E5:5C:7A:5D:22:16:7C:A4:8B:28:95:DF:B8:A3:F5:7C:06:92:F0:73:79:36" |
|||
] |
|||
} |
|||
}] |
|||
@ -0,0 +1,121 @@ |
|||
# HikeMap APK Build Instructions |
|||
|
|||
Your HikeMap PWA is ready to be converted to an APK! Here are three methods to create an installable Android app: |
|||
|
|||
## Method 1: Online Converter (Easiest - No coding required) |
|||
|
|||
### Using PWA2APK.com: |
|||
1. Visit https://pwa2apk.com |
|||
2. Enter your app URL: `https://maps.bibbit.duckdns.org` |
|||
3. Click "Start" |
|||
4. Fill in the form: |
|||
- App Name: HikeMap Trail Navigator |
|||
- Short Name: HikeMap |
|||
- Package ID: org.duckdns.bibbit.hikemap |
|||
5. Click "Generate APK" |
|||
6. Download the APK file |
|||
7. Share with users - they can install directly! |
|||
|
|||
### Using PWABuilder.com (Microsoft's Tool): |
|||
1. Visit https://www.pwabuilder.com |
|||
2. Enter URL: `https://maps.bibbit.duckdns.org` |
|||
3. Click "Start" |
|||
4. Review the PWA score (should be high!) |
|||
5. Click "Package for stores" |
|||
6. Select "Android" |
|||
7. Download the APK package |
|||
|
|||
## Method 2: Using Bubblewrap (Advanced - Full control) |
|||
|
|||
If you want to build locally with full customization: |
|||
|
|||
```bash |
|||
# Install required tools |
|||
sudo apt-get install openjdk-11-jdk android-sdk |
|||
|
|||
# Install Bubblewrap globally |
|||
npm install -g @bubblewrap/cli |
|||
|
|||
# Initialize your TWA project |
|||
bubblewrap init --manifest="https://maps.bibbit.duckdns.org/manifest.json" |
|||
|
|||
# Build the APK |
|||
bubblewrap build |
|||
|
|||
# The APK will be in: app-release-signed.apk |
|||
``` |
|||
|
|||
## Method 3: Android Studio (Most Control) |
|||
|
|||
1. Download Android Studio |
|||
2. Create new project → "Empty Activity" |
|||
3. Add TWA (Trusted Web Activity) support |
|||
4. Configure `AndroidManifest.xml` with your URL |
|||
5. Build → Generate Signed Bundle/APK |
|||
|
|||
## APK Features |
|||
|
|||
Your generated APK will have: |
|||
- ✅ Full offline support (Service Worker caching) |
|||
- ✅ Push notifications |
|||
- ✅ GPS location access |
|||
- ✅ Camera access (for future features) |
|||
- ✅ Install to home screen |
|||
- ✅ Runs in fullscreen (no browser UI) |
|||
- ✅ Auto-updates from your server |
|||
|
|||
## Sharing the APK |
|||
|
|||
Once you have the APK file: |
|||
|
|||
1. **Direct Install**: Users enable "Install from unknown sources" in Android settings |
|||
2. **Email/Message**: Send the APK file directly |
|||
3. **Download Link**: Host on your server at `https://maps.bibbit.duckdns.org/hikemap.apk` |
|||
4. **QR Code**: Generate QR code linking to the APK |
|||
|
|||
## Google Play Store (Optional) |
|||
|
|||
To publish on Play Store: |
|||
1. Create Google Play Developer account ($25 one-time fee) |
|||
2. Use the AAB (Android App Bundle) format instead of APK |
|||
3. Add the assetlinks.json file to your server |
|||
4. Submit for review |
|||
|
|||
## Testing the APK |
|||
|
|||
Before sharing: |
|||
1. Test on multiple Android versions (7.0+) |
|||
2. Verify GPS works properly |
|||
3. Test push notifications |
|||
4. Check offline functionality |
|||
5. Ensure all icons display correctly |
|||
|
|||
## Current Configuration |
|||
|
|||
Your app is configured with: |
|||
- Package ID: `org.duckdns.bibbit.hikemap` |
|||
- Version: 1.0.0 |
|||
- Min Android: 7.0 (API 24) |
|||
- Target Android: Latest |
|||
- Orientation: Portrait |
|||
- Theme Color: #4CAF50 |
|||
|
|||
## Troubleshooting |
|||
|
|||
**"App not installed" error**: |
|||
- Enable "Install unknown apps" for your browser |
|||
- Uninstall any previous version first |
|||
|
|||
**Notifications not working**: |
|||
- Ensure HTTPS is working |
|||
- Check notification permissions in Android settings |
|||
|
|||
**GPS not working**: |
|||
- Grant location permission when prompted |
|||
- Check location services are enabled |
|||
|
|||
--- |
|||
|
|||
## Quick Start (Recommended) |
|||
|
|||
For fastest results, use **PWA2APK.com** - it takes about 2 minutes and produces a ready-to-share APK file that your users can install immediately! |
|||
@ -0,0 +1,13 @@ |
|||
const webpush = require('web-push'); |
|||
|
|||
// Generate VAPID keys
|
|||
const vapidKeys = webpush.generateVAPIDKeys(); |
|||
|
|||
console.log('Add these to your .env file:'); |
|||
console.log(''); |
|||
console.log(`VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`); |
|||
console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`); |
|||
console.log(`VAPID_EMAIL=mailto:admin@maps.bibbit.duckdns.org`); |
|||
console.log(''); |
|||
console.log('Public key for client-side:'); |
|||
console.log(vapidKeys.publicKey); |
|||
@ -0,0 +1,82 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
Generate PWA icons from SVG or create simple placeholder icons |
|||
""" |
|||
|
|||
from PIL import Image, ImageDraw, ImageFont |
|||
import os |
|||
|
|||
def create_icon(size): |
|||
"""Create a simple app icon with the specified size.""" |
|||
# Create a new image with a green background |
|||
img = Image.new('RGBA', (size, size), color='#4CAF50') |
|||
draw = ImageDraw.Draw(img) |
|||
|
|||
# Draw a white mountain shape |
|||
mountain_color = 'white' |
|||
# Mountain 1 (left) |
|||
mountain1 = [ |
|||
(size * 0.1, size * 0.7), |
|||
(size * 0.35, size * 0.3), |
|||
(size * 0.6, size * 0.7) |
|||
] |
|||
draw.polygon(mountain1, fill=mountain_color) |
|||
|
|||
# Mountain 2 (right, slightly overlapping) |
|||
mountain2 = [ |
|||
(size * 0.4, size * 0.7), |
|||
(size * 0.65, size * 0.4), |
|||
(size * 0.9, size * 0.7) |
|||
] |
|||
draw.polygon(mountain2, fill='#E8F5E9') |
|||
|
|||
# Draw a location pin at the peak |
|||
pin_center = (int(size * 0.65), int(size * 0.4)) |
|||
pin_radius = int(size * 0.05) |
|||
|
|||
# Pin circle |
|||
draw.ellipse( |
|||
[pin_center[0] - pin_radius, pin_center[1] - pin_radius, |
|||
pin_center[0] + pin_radius, pin_center[1] + pin_radius], |
|||
fill='#FF5722' |
|||
) |
|||
|
|||
# Pin point |
|||
draw.polygon([ |
|||
(pin_center[0] - pin_radius, pin_center[1]), |
|||
(pin_center[0] + pin_radius, pin_center[1]), |
|||
(pin_center[0], pin_center[1] + pin_radius * 2) |
|||
], fill='#FF5722') |
|||
|
|||
# Add "HM" text at the bottom |
|||
try: |
|||
# Try to use a default font, fall back to PIL default if not available |
|||
font_size = int(size * 0.15) |
|||
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size) |
|||
except: |
|||
font = ImageFont.load_default() |
|||
|
|||
text = "HM" |
|||
bbox = draw.textbbox((0, 0), text, font=font) |
|||
text_width = bbox[2] - bbox[0] |
|||
text_height = bbox[3] - bbox[1] |
|||
text_x = (size - text_width) // 2 |
|||
text_y = int(size * 0.75) |
|||
|
|||
draw.text((text_x, text_y), text, fill='white', font=font) |
|||
|
|||
return img |
|||
|
|||
# Icon sizes needed for PWA |
|||
sizes = [72, 96, 128, 144, 152, 192, 384, 512] |
|||
|
|||
print("Generating HikeMap PWA icons...") |
|||
|
|||
for size in sizes: |
|||
icon = create_icon(size) |
|||
filename = f"icon-{size}x{size}.png" |
|||
icon.save(filename, "PNG") |
|||
print(f"Created {filename}") |
|||
|
|||
print("\nAll icons generated successfully!") |
|||
print("Icons feature a mountain landscape with location pin design.") |
|||
|
After Width: 128 | Height: 128 | Size: 1.2 KiB |
|
After Width: 144 | Height: 144 | Size: 1.4 KiB |
|
After Width: 152 | Height: 152 | Size: 1.4 KiB |
|
After Width: 192 | Height: 192 | Size: 1.8 KiB |
|
After Width: 384 | Height: 384 | Size: 3.7 KiB |
|
After Width: 512 | Height: 512 | Size: 5.1 KiB |
|
After Width: 72 | Height: 72 | Size: 721 B |
|
After Width: 96 | Height: 96 | Size: 947 B |
@ -0,0 +1,94 @@ |
|||
{ |
|||
"name": "HikeMap Trail Navigator", |
|||
"short_name": "HikeMap", |
|||
"description": "GPS trail navigation, tracking, and geocaching app for hikers", |
|||
"start_url": "https://maps.bibbit.duckdns.org/", |
|||
"scope": "https://maps.bibbit.duckdns.org/", |
|||
"display": "standalone", |
|||
"theme_color": "#4CAF50", |
|||
"background_color": "#ffffff", |
|||
"orientation": "portrait-primary", |
|||
"categories": ["navigation", "sports", "travel"], |
|||
"icons": [ |
|||
{ |
|||
"src": "/icon-72x72.png", |
|||
"sizes": "72x72", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-96x96.png", |
|||
"sizes": "96x96", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-128x128.png", |
|||
"sizes": "128x128", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-144x144.png", |
|||
"sizes": "144x144", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-152x152.png", |
|||
"sizes": "152x152", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-192x192.png", |
|||
"sizes": "192x192", |
|||
"type": "image/png", |
|||
"purpose": "any maskable" |
|||
}, |
|||
{ |
|||
"src": "/icon-384x384.png", |
|||
"sizes": "384x384", |
|||
"type": "image/png", |
|||
"purpose": "any" |
|||
}, |
|||
{ |
|||
"src": "/icon-512x512.png", |
|||
"sizes": "512x512", |
|||
"type": "image/png", |
|||
"purpose": "any maskable" |
|||
} |
|||
], |
|||
"screenshots": [ |
|||
{ |
|||
"src": "/screenshot-1.png", |
|||
"sizes": "540x720", |
|||
"type": "image/png", |
|||
"label": "Trail navigation view" |
|||
}, |
|||
{ |
|||
"src": "/screenshot-2.png", |
|||
"sizes": "540x720", |
|||
"type": "image/png", |
|||
"label": "Track editing tools" |
|||
} |
|||
], |
|||
"shortcuts": [ |
|||
{ |
|||
"name": "Start Navigation", |
|||
"short_name": "Navigate", |
|||
"description": "Start navigation mode", |
|||
"url": "/?mode=navigate", |
|||
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }] |
|||
}, |
|||
{ |
|||
"name": "Edit Tracks", |
|||
"short_name": "Edit", |
|||
"description": "Open track editor", |
|||
"url": "/?mode=edit", |
|||
"icons": [{ "src": "/icon-96x96.png", "sizes": "96x96" }] |
|||
} |
|||
], |
|||
"prefer_related_applications": false, |
|||
"related_applications": [] |
|||
} |
|||
4001
package-lock.json
File diff suppressed because it is too large
View File
@ -0,0 +1,214 @@ |
|||
// HikeMap Service Worker
|
|||
const CACHE_NAME = 'hikemap-v1.0.0'; |
|||
const urlsToCache = [ |
|||
'/', |
|||
'/index.html', |
|||
'/manifest.json', |
|||
'/default.kml', |
|||
'/icon-192x192.png', |
|||
'/icon-512x512.png', |
|||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', |
|||
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', |
|||
'https://unpkg.com/leaflet-rotate@0.2.8/dist/leaflet-rotate-src.js', |
|||
'https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css' |
|||
]; |
|||
|
|||
// Cache map tiles separately with a different strategy
|
|||
const MAP_TILE_CACHE = 'hikemap-tiles-v1'; |
|||
const GEOCACHE_CACHE = 'hikemap-geocaches-v1'; |
|||
|
|||
// Install event - cache essential files
|
|||
self.addEventListener('install', event => { |
|||
event.waitUntil( |
|||
caches.open(CACHE_NAME) |
|||
.then(cache => { |
|||
console.log('Opened cache'); |
|||
return cache.addAll(urlsToCache); |
|||
}) |
|||
.then(() => self.skipWaiting()) |
|||
); |
|||
}); |
|||
|
|||
// Activate event - clean up old caches
|
|||
self.addEventListener('activate', event => { |
|||
event.waitUntil( |
|||
caches.keys().then(cacheNames => { |
|||
return Promise.all( |
|||
cacheNames.map(cacheName => { |
|||
if (cacheName !== CACHE_NAME && |
|||
cacheName !== MAP_TILE_CACHE && |
|||
cacheName !== GEOCACHE_CACHE) { |
|||
console.log('Deleting old cache:', cacheName); |
|||
return caches.delete(cacheName); |
|||
} |
|||
}) |
|||
); |
|||
}).then(() => self.clients.claim()) |
|||
); |
|||
}); |
|||
|
|||
// Fetch event - serve from cache when possible
|
|||
self.addEventListener('fetch', event => { |
|||
const url = new URL(event.request.url); |
|||
|
|||
// Handle map tiles with cache-first strategy
|
|||
if (url.hostname.includes('tile.openstreetmap.org') || |
|||
url.hostname.includes('mt0.google.com') || |
|||
url.hostname.includes('mt1.google.com')) { |
|||
event.respondWith( |
|||
caches.open(MAP_TILE_CACHE).then(cache => { |
|||
return cache.match(event.request).then(response => { |
|||
if (response) { |
|||
return response; |
|||
} |
|||
// Fetch and cache the tile
|
|||
return fetch(event.request).then(response => { |
|||
// Only cache successful responses
|
|||
if (response.status === 200) { |
|||
cache.put(event.request, response.clone()); |
|||
} |
|||
return response; |
|||
}).catch(() => { |
|||
// Return a placeholder tile if offline
|
|||
return new Response('', { status: 204 }); |
|||
}); |
|||
}); |
|||
}) |
|||
); |
|||
return; |
|||
} |
|||
|
|||
// Handle API calls with network-first strategy
|
|||
if (url.pathname.includes('/save-kml') || |
|||
url.pathname.includes('/geocaches')) { |
|||
event.respondWith( |
|||
fetch(event.request) |
|||
.then(response => { |
|||
// Cache successful API responses
|
|||
if (response.status === 200) { |
|||
const responseToCache = response.clone(); |
|||
caches.open(GEOCACHE_CACHE).then(cache => { |
|||
cache.put(event.request, responseToCache); |
|||
}); |
|||
} |
|||
return response; |
|||
}) |
|||
.catch(() => { |
|||
// Fall back to cache for API calls
|
|||
return caches.match(event.request); |
|||
}) |
|||
); |
|||
return; |
|||
} |
|||
|
|||
// Default strategy: cache-first for assets
|
|||
event.respondWith( |
|||
caches.match(event.request) |
|||
.then(response => { |
|||
if (response) { |
|||
return response; |
|||
} |
|||
// Clone the request because it's a stream
|
|||
const fetchRequest = event.request.clone(); |
|||
|
|||
return fetch(fetchRequest).then(response => { |
|||
// Check if valid response
|
|||
if (!response || response.status !== 200 || response.type !== 'basic') { |
|||
return response; |
|||
} |
|||
|
|||
// Clone the response because it's a stream
|
|||
const responseToCache = response.clone(); |
|||
|
|||
caches.open(CACHE_NAME).then(cache => { |
|||
cache.put(event.request, responseToCache); |
|||
}); |
|||
|
|||
return response; |
|||
}); |
|||
}) |
|||
.catch(() => { |
|||
// Offline fallback
|
|||
if (event.request.destination === 'document') { |
|||
return caches.match('/index.html'); |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
// Background sync for uploading tracks when back online
|
|||
self.addEventListener('sync', event => { |
|||
if (event.tag === 'sync-tracks') { |
|||
event.waitUntil(syncTracks()); |
|||
} |
|||
}); |
|||
|
|||
async function syncTracks() { |
|||
// This would sync any offline changes when connection is restored
|
|||
console.log('Syncing tracks with server...'); |
|||
// Implementation would go here
|
|||
} |
|||
|
|||
// Handle messages from the main app
|
|||
self.addEventListener('message', event => { |
|||
if (event.data && event.data.type === 'SKIP_WAITING') { |
|||
self.skipWaiting(); |
|||
} |
|||
}); |
|||
|
|||
// Handle push notifications
|
|||
self.addEventListener('push', event => { |
|||
if (!event.data) { |
|||
console.log('Push notification without data'); |
|||
return; |
|||
} |
|||
|
|||
const options = event.data.json(); |
|||
|
|||
event.waitUntil( |
|||
self.registration.showNotification(options.title || 'HikeMap Alert', { |
|||
body: options.body || 'You have a new notification', |
|||
icon: options.icon || '/icon-192x192.png', |
|||
badge: options.badge || '/icon-72x72.png', |
|||
vibrate: [200, 100, 200], |
|||
tag: options.tag || 'hikemap-notification', |
|||
data: options.data || {}, |
|||
actions: [ |
|||
{ |
|||
action: 'view', |
|||
title: 'View', |
|||
icon: '/icon-72x72.png' |
|||
}, |
|||
{ |
|||
action: 'close', |
|||
title: 'Dismiss' |
|||
} |
|||
] |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
// Handle notification clicks
|
|||
self.addEventListener('notificationclick', event => { |
|||
event.notification.close(); |
|||
|
|||
if (event.action === 'close') { |
|||
return; |
|||
} |
|||
|
|||
// Open or focus the app
|
|||
event.waitUntil( |
|||
clients.matchAll({ type: 'window' }).then(clientList => { |
|||
// If app is already open, focus it
|
|||
for (const client of clientList) { |
|||
if (client.url.includes('maps.bibbit.duckdns.org') && 'focus' in client) { |
|||
return client.focus(); |
|||
} |
|||
} |
|||
// If app is not open, open it
|
|||
if (clients.openWindow) { |
|||
return clients.openWindow('/'); |
|||
} |
|||
}) |
|||
); |
|||
}); |
|||
@ -0,0 +1,54 @@ |
|||
{ |
|||
"packageId": "org.duckdns.bibbit.hikemap", |
|||
"host": "maps.bibbit.duckdns.org", |
|||
"name": "HikeMap Trail Navigator", |
|||
"launcherName": "HikeMap", |
|||
"display": "standalone", |
|||
"themeColor": "#4CAF50", |
|||
"navigationColor": "#4CAF50", |
|||
"backgroundColor": "#ffffff", |
|||
"enableNotifications": true, |
|||
"startUrl": "/", |
|||
"iconUrl": "https://maps.bibbit.duckdns.org/icon-512x512.png", |
|||
"maskableIconUrl": "https://maps.bibbit.duckdns.org/icon-512x512.png", |
|||
"splashScreenFadeOutDuration": 300, |
|||
"signingKey": { |
|||
"alias": "android", |
|||
"path": "./android.keystore", |
|||
"password": "hikemap2024", |
|||
"keyPassword": "hikemap2024" |
|||
}, |
|||
"appVersionName": "1.0.0", |
|||
"appVersionCode": 1, |
|||
"shortcuts": [ |
|||
{ |
|||
"name": "Navigate", |
|||
"shortName": "Navigate", |
|||
"url": "/?mode=navigate", |
|||
"chosenIconUrl": "https://maps.bibbit.duckdns.org/icon-96x96.png" |
|||
} |
|||
], |
|||
"generatorApp": "bubblewrap-cli", |
|||
"webManifestUrl": "https://maps.bibbit.duckdns.org/manifest.json", |
|||
"fallbackType": "customtabs", |
|||
"features": { |
|||
"locationDelegation": { |
|||
"enabled": true |
|||
}, |
|||
"playBilling": { |
|||
"enabled": false |
|||
} |
|||
}, |
|||
"alphaDependencies": { |
|||
"enabled": false |
|||
}, |
|||
"enableSiteSettingsShortcut": true, |
|||
"isChromeOSOnly": false, |
|||
"orientation": "portrait-primary", |
|||
"fingerprints": [ |
|||
{ |
|||
"value": "4B:99:EB:12:8C:5C:7B:9B:3C:2F:E5:5C:7A:5D:22:16:7C:A4:8B:28:95:DF:B8:A3:F5:7C:06:92:F0:73:79:36" |
|||
} |
|||
], |
|||
"appVersion": "1.0.0" |
|||
} |
|||