diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d67b73e..ef55ef2 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,9 @@ "Bash(dart:*)", "Bash(flutter pub:*)", "Bash(flutter clean:*)", - "Bash(flutter build:*)" + "Bash(flutter build:*)", + "Bash(xcrun simctl:*)", + "WebFetch(domain:github.com)" ], "deny": [], "ask": [] diff --git a/AUTO_RECOVERY.md b/AUTO_RECOVERY.md new file mode 100644 index 0000000..2716d99 --- /dev/null +++ b/AUTO_RECOVERY.md @@ -0,0 +1,310 @@ +# Auto-Recovery System for ERR_CODE_NOT_FOUND + +## Problem Statement + +When sending a message to a room contact that exists in the app's contact list but not in the companion radio's contact table, the radio responds with: +``` +ERROR (0x01) with error code 2 (ERR_CODE_NOT_FOUND) +``` + +This happens because: +1. Room contacts may have been deleted from the radio +2. New room contacts added to the app haven't been synced to the radio yet +3. The radio was factory reset but the app still has cached contacts + +## Solution: Automatic Contact Recovery + +The system now automatically detects and recovers from `ERR_CODE_NOT_FOUND` errors by: + +1. **Detecting the error** in `BleResponseHandler` (ble_response_handler.dart:584-587) +2. **Tracking the failing contact** via `setLastContactPublicKey()` +3. **Triggering auto-recovery** via `onContactNotFound` callback +4. **Adding the missing contact** to the radio using `CMD_ADD_UPDATE_CONTACT` +5. **Retrying the send operation** automatically after 300ms delay + +## Implementation Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ User sends message to room contact │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ConnectionProvider.sendTextMessage() │ +│ - Tracks pending operation: _PendingSendOperation │ +│ - Contains: contactPublicKey, text, messageId, contact │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MeshCoreBleService.sendTextMessage() │ +│ - Calls: responseHandler.setLastContactPublicKey() │ +│ - Sends: CMD_SEND_TXT_MSG (0x02) │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Radio processes command │ +│ ❌ Contact not found in radio's contact table │ +│ → Responds: RESP_CODE_ERR (0x01) with errCode=2 │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ BleResponseHandler._handleError() │ +│ - Detects: errorCode == 2 (ERR_CODE_NOT_FOUND) │ +│ - Triggers: onContactNotFound(lastContactPublicKey) │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ ConnectionProvider.onContactNotFound callback │ +│ 1. Looks up pending operation by public key │ +│ 2. Calls: bleService.addOrUpdateContact(contact) │ +│ → Sends: CMD_ADD_UPDATE_CONTACT (0x09) │ +│ 3. Waits 300ms for contact to be added │ +│ 4. Retries: bleService.sendTextMessage() │ +│ → Sends: CMD_SEND_TXT_MSG (0x02) again │ +│ 5. Clears pending operation on success │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Radio processes retry │ +│ ✅ Contact now exists in radio's contact table │ +│ → Responds: RESP_CODE_SENT (0x06) with ACK tag │ +└─────────────────┬───────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Message sent successfully │ +│ → UI shows "sent" status │ +│ → Normal delivery confirmation flow continues │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Key Components + +### 1. BleResponseHandler (lib/services/ble/ble_response_handler.dart) + +**New Callbacks:** +```dart +typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); +``` + +**Error Detection:** +```dart +void _handleError(BufferReader reader) { + final errorCode = FrameParser.parseError(reader); + if (errorCode == 2) { // ERR_CODE_NOT_FOUND + onContactNotFound?.call(_lastContactPublicKey); + } + onError?.call(errorMsg, errorCode: errorCode); +} +``` + +**Tracking:** +```dart +void setLastContactPublicKey(Uint8List? publicKey) { + _lastContactPublicKey = publicKey; +} +``` + +### 2. MeshCoreBleService (lib/services/meshcore_ble_service.dart) + +**Updated Typedef:** +```dart +typedef OnErrorCallback = void Function(String error, {int? errorCode}); +typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); +``` + +**Callback Forwarding:** +```dart +_responseHandler.onError = (error, {int? errorCode}) { + onError?.call(error, errorCode: errorCode); +}; +_responseHandler.onContactNotFound = (contactPublicKey) { + onContactNotFound?.call(contactPublicKey); +}; +``` + +**Contact Tracking:** +```dart +Future sendTextMessage({...}) async { + // Track the last contact for auto-recovery if contact not found + _responseHandler.setLastContactPublicKey(contactPublicKey); + await _commandSender.writeData(...); +} +``` + +### 3. ConnectionProvider (lib/providers/connection_provider.dart) + +**Pending Operation Tracking:** +```dart +class _PendingSendOperation { + final Uint8List contactPublicKey; + final String text; + final String? messageId; + final Contact? contact; + final int retryAttempt; +} + +final Map _pendingSendOperations = {}; +``` + +**Auto-Recovery Logic:** +```dart +_bleService.onContactNotFound = (contactPublicKey) async { + // 1. Look up pending operation + final pendingOp = _pendingSendOperations[operationId]; + + // 2. Add contact to radio + await _bleService.addOrUpdateContact(pendingOp.contact!); + await Future.delayed(const Duration(milliseconds: 300)); + + // 3. Retry send + await _bleService.sendTextMessage( + contactPublicKey: pendingOp.contactPublicKey, + text: pendingOp.text, + attempt: pendingOp.retryAttempt, + ); + + // 4. Cleanup + _pendingSendOperations.remove(operationId); +}; +``` + +**Send Message Tracking:** +```dart +Future sendTextMessage({...}) async { + // Track operation before sending + if (contact != null) { + _pendingSendOperations[operationId] = _PendingSendOperation(...); + } + + await _bleService.sendTextMessage(...); + + // Clear after 500ms (if no error occurs) + Future.delayed(const Duration(milliseconds: 500), () { + _pendingSendOperations.remove(operationId); + }); +} +``` + +## Testing Scenarios + +### Scenario 1: Room contact deleted from radio +1. User has room "SAR-Command" in app's contact list +2. Room was deleted from companion radio +3. User sends SAR marker to room +4. **Expected:** System automatically adds room and sends message +5. **Verify:** Message appears as "sent" in UI + +### Scenario 2: New room added to app +1. User manually adds new room contact to app +2. Room not yet in companion radio's contact table +3. User sends message to new room +4. **Expected:** System automatically adds room and sends message +5. **Verify:** Message appears as "sent" in UI + +### Scenario 3: Factory reset radio +1. Radio was factory reset (all contacts cleared) +2. App still has cached contacts +3. User sends message to any room +4. **Expected:** System automatically re-adds contact and sends message +5. **Verify:** Message appears as "sent" in UI + +## Console Output Example + +**Before (failed send):** +``` +📤 [TX] Sending command: SEND_TXT_MSG (0x02) + Data size: 46 bytes +📥 [RX] Received: ERROR (0x01) + ❌ [Error] Not found +⚠️ [Provider] BLE error received: Not found +``` + +**After (auto-recovery):** +``` +📤 [TX] Sending command: SEND_TXT_MSG (0x02) + Data size: 46 bytes + 📝 Tracked pending operation for auto-recovery: 8f:a0:f2:68:d0:c1 +📥 [RX] Received: ERROR (0x01) + ❌ [Error] Not found + ⚠️ [Error] Contact not found in radio - attempting auto-recovery +🔧 [Provider] Contact not found error detected - initiating auto-recovery + 📋 Found pending operation for: SAR-Command + 📤 Step 1: Adding contact to radio... +📝 [BLE] Adding/updating contact on companion radio: + Name: SAR-Command + Public key prefix: 8f:a0:f2:68:d0:c1 + Type: room (3) +📤 [TX] Sending command: ADD_UPDATE_CONTACT (0x09) +📥 [RX] Received: OK (0x00) + ✅ Contact added successfully + 🔄 Step 2: Retrying message send... +📤 [TX] Sending command: SEND_TXT_MSG (0x02) +📥 [RX] Received: SENT (0x06) + ✅ Auto-recovery completed - message resent +``` + +## Error Handling + +### If contact is null +``` +⚠️ No pending operation found for recovery: 8f:a0:f2:68:d0:c1 +``` +This happens if the send was called without a Contact object. Cannot auto-recover. + +### If add contact fails +``` +❌ Auto-recovery failed: BLE write error +``` +Error is logged and operation is cleared. User must manually retry. + +### If retry send fails +Same as above - error logged, operation cleared. + +## Benefits + +1. **Zero user intervention** - automatic recovery +2. **Transparent operation** - user sees "sent" status as expected +3. **Robust handling** - works for all ERR_CODE_NOT_FOUND scenarios +4. **Minimal latency** - 300ms delay is imperceptible to user +5. **Clean architecture** - isolated to ConnectionProvider layer + +## Future Enhancements + +1. **Batch recovery** - if multiple contacts missing, add all at once +2. **Proactive sync** - periodically sync app contacts to radio +3. **Smart caching** - track which contacts have been successfully added +4. **UI feedback** - optional: show "Adding contact..." toast during recovery +5. **Telemetry** - track auto-recovery success rate for monitoring + +## Related Files + +- `lib/services/ble/ble_response_handler.dart` - Error detection +- `lib/services/meshcore_ble_service.dart` - Service layer coordination +- `lib/providers/connection_provider.dart` - Auto-recovery orchestration +- `lib/services/protocol/frame_parser.dart` - Error code parsing +- `lib/services/meshcore_constants.dart` - Error code constants + +## MeshCore Protocol Reference + +**Error Codes (ERR_CODE):** +- 1: ERR_CODE_UNSUPPORTED_CMD +- **2: ERR_CODE_NOT_FOUND** ← This is what we handle +- 3: ERR_CODE_TABLE_FULL +- 4: ERR_CODE_BAD_STATE +- 5: ERR_CODE_FILE_IO_ERROR +- 6: ERR_CODE_ILLEGAL_ARG + +**Recovery Commands:** +- CMD_ADD_UPDATE_CONTACT (9): Adds contact to radio's contact table +- CMD_SEND_TXT_MSG (2): Sends message to contact (retried after add) + +## Conclusion + +The auto-recovery system provides a seamless user experience by automatically handling missing contacts in the radio's contact table. Users can now send messages to room contacts without worrying about synchronization issues, and the system will intelligently recover from `ERR_CODE_NOT_FOUND` errors. diff --git a/BLOG_POST.md b/BLOG_POST.md new file mode 100644 index 0000000..3794905 --- /dev/null +++ b/BLOG_POST.md @@ -0,0 +1,289 @@ +# MeshCore SAR: Off-Grid Communication for Search & Rescue Operations + +When the grid goes down, lives are on the line. MeshCore SAR is a revolutionary mobile application that enables search and rescue teams to communicate, coordinate, and share critical information—even when cellular networks and internet connectivity are completely unavailable. + +Built on cutting-edge mesh networking technology, MeshCore SAR transforms ordinary smartphones into powerful off-grid communication devices using low-power radio hardware. Whether you're coordinating a wilderness rescue, managing a disaster response, or operating in remote areas, MeshCore SAR keeps your team connected when it matters most. + +## Why MeshCore SAR? + +Traditional communication systems fail when you need them most: +- **Cellular networks** collapse during disasters or don't exist in remote wilderness +- **Satellite phones** are expensive and have limited messaging capabilities +- **Radio systems** require licensing and lack modern features like GPS integration + +MeshCore SAR solves these problems by creating a resilient, self-healing mesh network that: +- ✅ **Works completely off-grid** - No cellular, WiFi, or internet required +- ✅ **Extends range through mesh routing** - Messages hop through nearby devices +- ✅ **Integrates GPS tracking** - Real-time location sharing on offline maps +- ✅ **Specializes in SAR operations** - Purpose-built features for emergency response +- ✅ **Uses affordable hardware** - Low-cost LoRa radios via Bluetooth + +--- + +## 🗨️ Messages: Reliable Communication When Networks Fail + +[*Image placeholder: Messages screen showing conversation with delivery status*] + +At the heart of MeshCore SAR is a robust messaging system designed for mission-critical communication. + +### Key Features: + +**🎯 Multiple Communication Modes** +- **Direct Messages**: Private one-to-one communication with team members +- **Public Channel**: Broadcast updates to all nearby devices +- **Rooms**: Persistent message storage for coordination centers + +**📡 Smart Message Delivery** +- **Intelligent routing**: Messages automatically find the best path through the mesh network +- **Delivery confirmation**: Know when your message reaches its destination with ACK tracking +- **Automatic retries**: Failed messages retry automatically with progressive timeouts +- **Flood fallback**: Critical messages use broadcast mode if routing fails + +**🚨 SAR Marker Messages** +Send location-tagged alerts with a simple message format: +- **🧑 Found Person**: `S:🧑:37.7749,-122.4194:Survivor located, needs medical attention` +- **🔥 Fire Location**: `S:🔥:40.7128,-74.0060:Wildfire spreading rapidly northeast` +- **🏕️ Staging Area**: `S:🏕️:34.0522,-118.2437:Base camp established with supplies` + +These special messages automatically appear as markers on the map, making critical information instantly visual for the entire team. + +**📊 Message Status Tracking** +Every message shows its delivery status: +- ⏳ **Sending** - Message is being transmitted +- ✅ **Sent** - Message queued with expected acknowledgment +- ✔️✔️ **Delivered** - Confirmation received with round-trip time +- 🔄 **Retrying** - Automatic retry in progress +- ❌ **Failed** - Delivery unsuccessful after all attempts + +**🌍 Multilingual Support** +Full localization in English, Croatian (Hrvatski), and Slovenian (Slovenščina) ensures teams can communicate in their native language. + +--- + +## 👥 Contacts: Know Your Team's Status and Location + +[*Image placeholder: Contacts list showing team members with GPS locations and battery levels*] + +MeshCore SAR's contact system goes beyond simple names and numbers—it provides real-time situational awareness for your entire team. + +### Contact Intelligence: + +**📍 Real-Time Location Tracking** +- GPS coordinates automatically broadcast at configurable intervals +- Location history tracking (last 100 positions per contact) +- Distance and bearing calculations from your position +- "Last seen" timestamps for situational awareness + +**🔋 Battery Monitoring** +- Battery percentage displayed for each team member +- Voltage telemetry via Cayenne LPP format +- Early warning when team members need to conserve power + +**🛤️ Mesh Network Routing** +The app shows routing information for each contact: +- **Direct (0 hops)**: Connected directly to your radio +- **Good path (1-2 hops)**: Reliable routing through 1-2 intermediate devices +- **Medium/Long path (3-5+ hops)**: Extended range through multiple hops +- **No path (flood mode)**: Messages broadcast to entire network + +**👔 Role-Based Identification** +- Add emoji prefixes to names (🧑🏻‍🚒 for firefighter, 👮 for police, 🏥 for medical) +- Instant visual identification on map and in contact lists +- Customizable display names + +**📡 Contact Types** +- **Chat** (Team Members): Standard team members shown on map +- **Repeater**: Network infrastructure nodes that extend range +- **Room**: Message servers with persistent storage and login capabilities + +**📞 Contact Sharing** +- Export contacts as business cards +- Share contacts directly over the mesh network +- Import contacts from other team members + +--- + +## 🗺️ Map: Offline Navigation and Tactical Awareness + +[*Image placeholder: Map screen showing team members, SAR markers, and offline terrain*] + +The map is where everything comes together—combining team locations, SAR events, and offline navigation into a single, comprehensive tactical display. + +### Map Features: + +**🗺️ Offline Vector Maps** +- **MBTiles format**: Lightweight vector maps that work completely offline +- **Multiple layers**: Street maps (OpenStreetMap), topographic (OpenTopoMap), satellite imagery (ESRI) +- **High zoom levels**: Street-level detail up to zoom level 19 +- **Smart caching**: Tiles cached locally for 30 days + +**📍 Team Member Tracking** +Each team member appears on the map with: +- Blue circle markers with role emoji +- Battery level badge (green/yellow/red indicators) +- Distance from your location +- Tap to view detailed information and message directly + +**🚨 SAR Event Markers** +Critical events appear as color-coded markers: +- 🟢 **Green** - Found Person +- 🔴 **Red** - Fire Location +- 🔵 **Blue** - Staging Area +- 🟣 **Purple** - Object +- Time elapsed since report +- Tap to navigate and view details + +**✏️ Map Drawing Tools** +Collaborative tactical planning: +- **Line drawings**: Sketch routes, boundaries, or directions +- **Rectangle areas**: Mark zones, perimeters, or sectors +- **8 color palette**: Red, blue, green, yellow, orange, purple, pink, cyan +- **Share drawings**: Send to channel or specific rooms via mesh network +- **Collaborative editing**: All team members see drawings in real-time +- **Ultra-compact format**: Efficient JSON encoding reduces bandwidth usage by 37% + +**🧭 Detailed Compass Dialog** +Ultra-compact location display: +- Current GPS coordinates +- Toggle between Decimal Degrees (DD) and Degrees/Minutes/Seconds (DMS) +- Tap outside to close (no buttons needed) +- Perfect for quick location checks + +**📡 User Location Tracking** +- Blue pulsing circle shows your current position +- Navigation icon for direction +- Tap to center and track your movement +- Configurable accuracy settings + +**📊 Map Legend** +Collapsible legend in top-right corner: +- Team member count +- SAR marker count by type +- Quick reference for marker colors + +**🎯 Smart Navigation** +- Tap any SAR marker in messages to navigate on map +- Automatic tab switching +- Map centers and zooms to selected location +- Clears navigation after viewing + +--- + +## 🔧 Technical Innovation + +### Mesh Network Protocol +- **MeshCore Protocol**: Open-source, battle-tested mesh networking +- **LoRa Radio**: Long-range, low-power wireless technology +- **BLE Connection**: Smartphone connects to companion radio via Bluetooth +- **Intelligent routing**: Self-healing paths through the network +- **Flood mode fallback**: Guaranteed delivery for critical messages + +### Smart Features +- **Adaptive location broadcasting**: Only sends updates when you move significantly +- **Progressive retry logic**: Failed messages retry with increasing timeouts +- **Cayenne LPP telemetry**: Standardized sensor data format +- **Contact synchronization**: Automatic contact list updates +- **Message persistence**: Rooms store messages for later retrieval + +### Built for Reliability +- **Provider-based architecture**: Efficient state management +- **Automatic reconnection**: Handles radio disconnections gracefully +- **Message queue management**: Synchronizes messages in order +- **Battery optimization**: Configurable tracking intervals +- **Memory management**: Automatic history cleanup + +--- + +## 🌟 Real-World Applications + +**Search & Rescue Operations** +- Wilderness rescue coordination +- Missing person searches +- Cave rescue operations +- Mountain rescue teams + +**Disaster Response** +- Hurricane and flood response +- Earthquake emergency communication +- Infrastructure failure scenarios +- Mass casualty incidents + +**Remote Operations** +- Forestry operations +- Mining site communication +- Border patrol and security +- Wildlife management + +**Training & Exercises** +- Team coordination drills +- Radio discipline training +- Navigation exercises +- Emergency preparedness + +--- + +## 🚀 Getting Started + +MeshCore SAR works with affordable LoRa companion radios that connect to your smartphone via Bluetooth. The app handles all the complexity—you just: + +1. **Connect** your radio via Bluetooth +2. **Add contacts** to your team +3. **Start messaging** and tracking locations +4. **Download maps** for your area +5. **Coordinate** your mission + +No cellular service. No internet. No limits. + +--- + +## 🌍 Open Source & Community-Driven + +MeshCore SAR is built on the open-source MeshCore protocol, fostering a community of developers and users who contribute to its continuous improvement. Whether you're a first responder, amateur radio enthusiast, or outdoor adventurer, you're part of a global network working to keep people connected when it matters most. + +--- + +## 💡 The Future of Off-Grid Communication + +In a world increasingly dependent on fragile infrastructure, MeshCore SAR represents a paradigm shift: resilient, decentralized communication that works when everything else fails. As climate change drives more frequent disasters and teams operate in increasingly remote locations, mesh networking isn't just an alternative—it's essential. + +**MeshCore SAR isn't just an app. It's a lifeline.** + +--- + +*MeshCore SAR is compatible with iOS 13+ and Android API 21+. Requires compatible LoRa companion radio hardware.* + +**Repository**: [github.com/meshcore-dev/meshcore.js](https://github.com/meshcore-dev/meshcore.js) + +--- + +## 📸 Screenshots + +*[Placeholder sections for images]* + +### Messages +*Screenshot showing conversation with delivery status, SAR marker messages, and message options* + +### Contacts +*Screenshot showing contact list with GPS locations, battery levels, and routing information* + +### Map - Team View +*Screenshot showing map with multiple team members, distance indicators, and user location* + +### Map - SAR Markers +*Screenshot showing map with various SAR markers (person, fire, staging area) and color coding* + +### Map - Drawing Tools +*Screenshot showing map with line and rectangle drawings, color palette, and toolbar* + +### Map - Offline Layers +*Screenshot showing different map layers (street, topographic, satellite) selection* + +### Settings +*Screenshot showing connection status, radio parameters, and location tracking settings* + +### Contact Details +*Screenshot showing detailed contact information, telemetry data, and message history* + +--- + +**Ready to experience off-grid communication?** Connect your radio and join the mesh network today. diff --git a/SCREENSHOTS.md b/SCREENSHOTS.md new file mode 100644 index 0000000..1780e9d --- /dev/null +++ b/SCREENSHOTS.md @@ -0,0 +1,367 @@ +# Screenshot Automation Guide + +Comprehensive guide for capturing App Store screenshots for MeshCore SAR app using Flutter integration tests. + +## Overview + +This project includes automated screenshot capture for: +- **App Store submission** (iOS + Android) +- **Documentation and training materials** +- **Marketing assets** +- **Multiple devices and screen sizes** +- **Multiple locales** (English, Croatian, Slovenian) + +## Quick Start + +### Prerequisites + +1. **Flutter SDK** installed and configured +2. **iOS**: Xcode with simulators installed +3. **Android**: Android Studio with emulators configured +4. **Dependencies installed**: + ```bash + flutter pub get + ``` + +### Take Screenshots (All Devices) + +```bash +./scripts/take_screenshots.sh +``` + +Screenshots will be saved to `screenshots/` directory. + +## Detailed Usage + +### Command Line Options + +```bash +# All devices (iOS + Android) +./scripts/take_screenshots.sh + +# iOS devices only +./scripts/take_screenshots.sh --ios + +# Android devices only +./scripts/take_screenshots.sh --android + +# Specific device +./scripts/take_screenshots.sh --device "iPhone 15 Pro Max" + +# List available devices +./scripts/take_screenshots.sh --list + +# Show help +./scripts/take_screenshots.sh --help +``` + +### Manual Test Execution + +You can also run the integration test manually: + +```bash +# iOS +flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/app_screenshots_test.dart \ + -d "iPhone 15 Pro Max" + +# Android (start emulator first) +flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/app_screenshots_test.dart \ + -d emulator-5554 +``` + +## Screenshot Coverage + +The automated test captures the following screens: + +1. **Home Screen (Disconnected)** - Initial state showing connect button +2. **Messages List** - Messages with SAR markers displayed +3. **SAR Marker Detail** - Detailed view of a SAR event +4. **Contacts List** - Team members and repeaters +5. **Contact Detail** - Individual contact information +6. **Map View** - Map with team markers and SAR markers +7. **Map Legend** - Legend showing marker types +8. **Settings Screen** - App settings and preferences + +## Device Configurations + +### iOS Devices (App Store Requirements) + +The script is configured for App Store screenshot requirements: + +| Device | Screen Size | Resolution | Required for App Store | +|--------|-------------|------------|----------------------| +| iPhone 15 Pro Max | 6.7" | 1290x2796 | ✅ Yes (primary) | +| iPhone 14 Pro Max | 6.7" | 1290x2796 | ✅ Yes (backup) | +| iPhone 8 Plus | 5.5" | 1242x2208 | ✅ Yes (smaller size) | + +**App Store Notes:** +- 6.7" display is **required** as of 2024 +- 5.5" display provides compatibility with older devices +- Screenshots must be in PNG or JPEG format +- Maximum 10 screenshots per device size + +### Android Devices (Google Play Requirements) + +| Device | Type | Resolution | Required for Play Store | +|--------|------|------------|------------------------| +| Pixel 7 Pro | Phone | 1440x3120 | ✅ Recommended | +| Pixel Tablet | Tablet | 2560x1600 | ✅ Recommended | + +**Google Play Notes:** +- Phone screenshots: 16:9 or 9:16 ratio recommended +- Tablet screenshots: Optional but recommended +- Minimum 2 screenshots, maximum 8 per device type +- PNG or JPEG format accepted + +## Project Structure + +``` +meshcore_sar_app/ +├── integration_test/ +│ ├── app_screenshots_test.dart # Main screenshot test +│ └── helpers/ +│ ├── mock_data.dart # Mock contacts, messages, markers +│ └── screenshot_helper.dart # Screenshot utilities +├── test_driver/ +│ └── integration_test.dart # Integration test driver +├── scripts/ +│ └── take_screenshots.sh # Automated screenshot script +└── screenshots/ # Output directory + ├── ios/ + │ ├── iPhone_15_Pro_Max/ + │ ├── iPhone_14_Pro_Max/ + │ └── iPhone_8_Plus/ + └── android/ + ├── pixel_7_pro/ + └── pixel_tablet/ +``` + +## Mock Data + +The test uses predictable mock data for consistent screenshots: + +### Contacts (6 total) +- **Alpha Team Lead** - Battery: 3850mV, 1 hop, -45 dBm +- **Bravo Scout** - Battery: 3700mV, 2 hops, -68 dBm +- **Charlie Base** - Battery: 4100mV, 0 hops, -35 dBm +- **Delta Medic** - Battery: 3600mV, 3 hops, -75 dBm +- **Mountain Repeater 1** - Repeater type +- **SAR Command Room** - Room type + +### Messages (8 total) +- Team communications +- SAR marker messages +- Public channel broadcasts + +### SAR Markers (3 total) +- 🧑 **Found Person** at 46.0589, 14.5078 (Bravo Scout) +- 🏕️ **Staging Area** at 46.0549, 14.5038 (Charlie Base) +- 🔥 **Fire Location** at 46.0620, 14.5120 (Alpha Team Lead) + +All mock data is defined in `integration_test/helpers/mock_data.dart`. + +## Customization + +### Adding More Screens + +Edit `integration_test/app_screenshots_test.dart`: + +```dart +// Navigate to your screen +await tester.tapAndSettle(find.text('Your Screen')); + +// Take screenshot +await screenshotHelper.takeScreenshot( + tester, + 'your_screen_name', +); +``` + +### Changing Mock Data + +Edit `integration_test/helpers/mock_data.dart`: + +```dart +static List getMockContacts() { + return [ + Contact( + publicKey: '0x...', + name: 'Your Contact Name', + // ... more fields + ), + ]; +} +``` + +### Adding Devices + +Edit `scripts/take_screenshots.sh`: + +```bash +# iOS +IOS_DEVICES=( + "iPhone 15 Pro Max" + "Your Device Name" +) + +# Android +ANDROID_DEVICES=( + "pixel_7_pro" + "your_emulator_name" +) +``` + +## Localization + +To capture screenshots in different languages: + +1. **Set system language** on simulator/emulator +2. **Run the screenshot script** +3. **Organize by locale** in output directory + +Example for Croatian screenshots: + +```bash +# 1. Set iOS simulator to Croatian +xcrun simctl spawn booted defaults write "Apple Global Domain" AppleLanguages -array hr + +# 2. Run screenshots +./scripts/take_screenshots.sh --ios + +# 3. Move to locale-specific folder +mkdir -p screenshots/ios/hr-HR +mv screenshots/ios/iPhone_15_Pro_Max/* screenshots/ios/hr-HR/ +``` + +For automation, you can modify the script to handle locale switching. + +## Troubleshooting + +### Simulator Not Found + +```bash +# List available simulators +xcrun simctl list devices available + +# Create new simulator +xcrun simctl create "iPhone 15 Pro Max" "iPhone 15 Pro Max" +``` + +### Emulator Issues + +```bash +# List available emulators +emulator -list-avds + +# Create new emulator (use Android Studio AVD Manager) +# Or via command line: +avdmanager create avd -n pixel_7_pro -k "system-images;android-33;google_apis;x86_64" +``` + +### Screenshots Not Appearing + +1. Check test output for errors +2. Verify `integration_test/app_screenshots_test.dart` runs successfully +3. Check `ScreenshotHelper` is calling `binding.takeScreenshot()` +4. Ensure output directory has write permissions + +### Test Times Out + +```bash +# Increase timeout in test +await tester.pumpAndSettle(const Duration(seconds: 10)); + +# Or modify flutter drive timeout +flutter drive --timeout=120s ... +``` + +### BLE/Permissions Errors in Tests + +Integration tests run in a sandboxed environment. The test uses **mock data** instead of real BLE connections, so: + +- ✅ No actual BLE device needed +- ✅ No location permissions required +- ✅ Predictable, repeatable screenshots +- ❌ Cannot test actual BLE connectivity (use manual testing for that) + +## Best Practices + +### For App Store Screenshots + +1. **Use the largest device first** (iPhone 15 Pro Max, Pixel 7 Pro) +2. **Highlight key features** in each screenshot +3. **Add localization** for target markets +4. **Keep consistent ordering** across all devices +5. **Review before submission** - ensure no sensitive data visible + +### For Quality Screenshots + +1. **Clean state** - Use mock data for predictable content +2. **Good lighting** - Ensure sufficient contrast in UI +3. **Meaningful content** - Show realistic usage scenarios +4. **No debug info** - Disable debug banners/overlays +5. **Proper timing** - Wait for animations to complete + +### File Organization + +Recommended structure for App Store submission: + +``` +screenshots/ +├── en-US/ # English (default) +│ ├── 6.7-inch/ # iPhone 15 Pro Max +│ │ ├── 01_home.png +│ │ ├── 02_messages.png +│ │ └── ... +│ ├── 5.5-inch/ # iPhone 8 Plus +│ └── android-phone/ # Pixel 7 Pro +├── hr-HR/ # Croatian +│ └── ... +└── sl-SI/ # Slovenian + └── ... +``` + +## Advanced: CI/CD Integration + +For automated screenshot generation in CI/CD pipelines: + +```yaml +# .github/workflows/screenshots.yml +name: Generate Screenshots +on: + workflow_dispatch: # Manual trigger + +jobs: + screenshots: + runs-on: macos-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + - name: Install dependencies + run: flutter pub get + - name: Take screenshots + run: ./scripts/take_screenshots.sh --ios + - name: Upload screenshots + uses: actions/upload-artifact@v3 + with: + name: screenshots + path: screenshots/ +``` + +## Resources + +- [Flutter Integration Testing](https://docs.flutter.dev/testing/integration-tests) +- [App Store Screenshot Requirements](https://developer.apple.com/help/app-store-connect/reference/screenshot-specifications/) +- [Google Play Screenshot Requirements](https://support.google.com/googleplay/android-developer/answer/9866151) +- [MeshCore SAR Documentation](./CLAUDE.md) + +## Support + +For issues or questions: +1. Check the [Troubleshooting](#troubleshooting) section +2. Review Flutter integration test docs +3. Open an issue in the project repository diff --git a/integration_test/app_screenshots_test.dart b/integration_test/app_screenshots_test.dart new file mode 100644 index 0000000..e8fb275 --- /dev/null +++ b/integration_test/app_screenshots_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:provider/provider.dart'; + +import 'package:meshcore_sar_app/main.dart'; +import 'package:meshcore_sar_app/providers/connection_provider.dart'; +import 'package:meshcore_sar_app/providers/contacts_provider.dart'; +import 'package:meshcore_sar_app/providers/messages_provider.dart'; +import 'package:meshcore_sar_app/providers/drawing_provider.dart'; +import 'package:meshcore_sar_app/providers/map_provider.dart'; + +import 'helpers/screenshot_helper.dart'; +import 'helpers/mock_data.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('App Screenshots', () { + late ScreenshotHelper screenshotHelper; + + testWidgets('Capture all app screens with mock data', (tester) async { + // Initialize screenshot helper + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + screenshotHelper = ScreenshotHelper(binding); + + // Pump the app + await tester.pumpWidget(const MeshCoreSarApp()); + await tester.pumpAndSettle(); + + // =================================================================== + // 1. DISCONNECTED STATE - Home Screen + // =================================================================== + await screenshotHelper.takeScreenshot( + tester, + 'home_disconnected', + wait: const Duration(seconds: 1), + ); + + // =================================================================== + // 2. SIMULATED CONNECTED STATE (inject mock data into providers) + // =================================================================== + // Note: Since we can't actually connect to a BLE device in tests, + // we'll need to manually inject mock data into the providers. + // This requires accessing the providers through the context. + + final context = tester.element(find.byType(MaterialApp)); + final contactsProvider = context.read(); + final messagesProvider = context.read(); + + // Inject mock contacts + final mockContacts = MockData.getMockContacts(); + for (final contact in mockContacts) { + contactsProvider.addOrUpdateContact(contact); + } + + // Inject mock messages + final mockMessages = MockData.getMockMessages(); + for (final message in mockMessages) { + messagesProvider.receiveMessage(message); + } + + // Inject mock SAR markers + final mockSarMarkers = MockData.getMockSarMarkers(); + for (final marker in mockSarMarkers) { + messagesProvider.addSarMarker(marker); + } + + await tester.pumpAndSettle(); + + // =================================================================== + // 3. MESSAGES TAB WITH DATA + // =================================================================== + // The app should default to Messages tab (index 0) + await screenshotHelper.takeScreenshot( + tester, + 'messages_list_with_sar_markers', + ); + + // Try to find and tap a SAR marker to show detail + final sarMarkerFinder = find.byWidgetPredicate( + (widget) => widget.runtimeType.toString().contains('SarMarker'), + ); + if (sarMarkerFinder.hasFound) { + await tester.tapAndSettle(sarMarkerFinder.first); + await screenshotHelper.takeScreenshot( + tester, + 'messages_sar_marker_detail', + ); + // Go back + await tester.pageBack(); + await tester.pumpAndSettle(); + } + + // =================================================================== + // 4. CONTACTS TAB + // =================================================================== + // Find and tap the Contacts tab + final contactsTab = find.text('Contacts'); + if (contactsTab.hasFound) { + await tester.tapAndSettle(contactsTab); + } else { + // Try icon-based navigation + final tabBar = find.byType(TabBar); + if (tabBar.hasFound) { + final contactsIcon = find.descendant( + of: tabBar, + matching: find.byIcon(Icons.contacts), + ); + await tester.tapAndSettle(contactsIcon); + } + } + + await screenshotHelper.takeScreenshot( + tester, + 'contacts_list_with_teams', + ); + + // Try to find and tap a contact to show detail + final contactListTile = find.byType(ListTile).first; + if (contactListTile.hasFound) { + await tester.tapAndSettle(contactListTile); + await screenshotHelper.takeScreenshot( + tester, + 'contacts_detail_dialog', + ); + // Close dialog (tap outside or back button) + await tester.pageBack(); + await tester.pumpAndSettle(); + } + + // =================================================================== + // 5. MAP TAB + // =================================================================== + // Find and tap the Map tab + final mapTab = find.text('Map'); + if (mapTab.hasFound) { + await tester.tapAndSettle(mapTab); + } else { + // Try icon-based navigation + final tabBar = find.byType(TabBar); + if (tabBar.hasFound) { + final mapIcon = find.descendant( + of: tabBar, + matching: find.byIcon(Icons.map), + ); + await tester.tapAndSettle(mapIcon); + } + } + + // Wait for map to load + await tester.pumpAndSettle(const Duration(seconds: 2)); + await screenshotHelper.takeScreenshot( + tester, + 'map_with_markers_and_sar', + ); + + // Try to find and open the map legend + final legendFinder = find.byWidgetPredicate( + (widget) => widget.runtimeType.toString().contains('Legend'), + ); + if (legendFinder.hasFound) { + await tester.tapAndSettle(legendFinder.first); + await screenshotHelper.takeScreenshot( + tester, + 'map_legend_expanded', + ); + } + + // =================================================================== + // 6. SETTINGS SCREEN (via menu) + // =================================================================== + // Go back to Messages tab + final messagesTab = find.text('Messages'); + if (messagesTab.hasFound) { + await tester.tapAndSettle(messagesTab); + } + + // Find and tap the menu button + final menuButton = find.byIcon(Icons.more_vert); + if (menuButton.hasFound) { + await tester.tapAndSettle(menuButton); + + // Find and tap Settings in the popup menu + final settingsMenuItem = find.text('Settings'); + if (settingsMenuItem.hasFound) { + await tester.tapAndSettle(settingsMenuItem); + await tester.pumpAndSettle(const Duration(seconds: 1)); + + await screenshotHelper.takeScreenshot( + tester, + 'settings_screen', + ); + + // Go back + await tester.pageBack(); + await tester.pumpAndSettle(); + } + } + + // =================================================================== + // 7. SIMULATED DEVICE CONNECTION DIALOG + // =================================================================== + // NOTE: This is challenging without actual BLE, but we can try to + // trigger the connection dialog + // For now, we'll skip this as it requires disconnecting first + + // =================================================================== + // SUMMARY + // =================================================================== + print('\n✅ Screenshot capture complete!'); + print('📸 Total screenshots taken: ${screenshotHelper.screenshotCount}'); + print('\nScreenshots are saved in the default integration test output directory.'); + print('To view them, check your flutter drive output folder.\n'); + }); + }); +} diff --git a/integration_test/helpers/mock_data.dart b/integration_test/helpers/mock_data.dart new file mode 100644 index 0000000..476fedd --- /dev/null +++ b/integration_test/helpers/mock_data.dart @@ -0,0 +1,230 @@ +import 'package:meshcore_sar_app/models/contact.dart'; +import 'package:meshcore_sar_app/models/message.dart'; +import 'package:meshcore_sar_app/models/sar_marker.dart'; + +/// Mock data for integration tests and screenshots +class MockData { + /// Generate mock contacts with predictable data + static List getMockContacts() { + return [ + Contact( + publicKey: '0x1111111111111111111111111111111111111111111111111111111111111111', + name: 'Alpha Team Lead', + contactType: ContactType.chat, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 2)), + lastLocation: const ContactLocation( + latitude: 46.0569, + longitude: 14.5058, + altitude: 295.0, + ), + batteryMillivolts: 3850, + hopCount: 1, + rssi: -45, + ), + Contact( + publicKey: '0x2222222222222222222222222222222222222222222222222222222222222222', + name: 'Bravo Scout', + contactType: ContactType.chat, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 5)), + lastLocation: const ContactLocation( + latitude: 46.0589, + longitude: 14.5078, + altitude: 310.0, + ), + batteryMillivolts: 3700, + hopCount: 2, + rssi: -68, + ), + Contact( + publicKey: '0x3333333333333333333333333333333333333333333333333333333333333333', + name: 'Charlie Base', + contactType: ContactType.chat, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 1)), + lastLocation: const ContactLocation( + latitude: 46.0549, + longitude: 14.5038, + altitude: 285.0, + ), + batteryMillivolts: 4100, + hopCount: 0, + rssi: -35, + ), + Contact( + publicKey: '0x4444444444444444444444444444444444444444444444444444444444444444', + name: 'Delta Medic', + contactType: ContactType.chat, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 8)), + lastLocation: const ContactLocation( + latitude: 46.0609, + longitude: 14.5098, + altitude: 320.0, + ), + batteryMillivolts: 3600, + hopCount: 3, + rssi: -75, + ), + Contact( + publicKey: '0x5555555555555555555555555555555555555555555555555555555555555555', + name: 'Mountain Repeater 1', + contactType: ContactType.repeater, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 1)), + lastLocation: const ContactLocation( + latitude: 46.0650, + longitude: 14.5150, + altitude: 450.0, + ), + batteryMillivolts: 4150, + hopCount: 0, + rssi: -40, + ), + Contact( + publicKey: '0x6666666666666666666666666666666666666666666666666666666666666666', + name: 'SAR Command Room', + contactType: ContactType.room, + lastAdvertisement: DateTime.now().subtract(const Duration(minutes: 30)), + hopCount: 1, + rssi: -50, + ), + ]; + } + + /// Generate mock messages with SAR markers + static List getMockMessages() { + final now = DateTime.now(); + return [ + Message( + id: 'msg1', + sender: '0x1111111111111111111111111111111111111111111111111111111111111111', + senderName: 'Alpha Team Lead', + content: 'Team Alpha in position, beginning sweep of sector 3', + timestamp: now.subtract(const Duration(minutes: 15)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg2', + sender: '0x2222222222222222222222222222222222222222222222222222222222222222', + senderName: 'Bravo Scout', + content: 'S:🧑:46.0589,14.5078:Found injured hiker near trail marker 7', + timestamp: now.subtract(const Duration(minutes: 12)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg3', + sender: 'self', + senderName: 'You', + content: 'Copy that Bravo, sending medic to your location', + timestamp: now.subtract(const Duration(minutes: 11)), + isSent: true, + isPublicChannel: false, + ), + Message( + id: 'msg4', + sender: '0x4444444444444444444444444444444444444444444444444444444444444444', + senderName: 'Delta Medic', + content: 'En route to Bravo position, ETA 5 minutes', + timestamp: now.subtract(const Duration(minutes: 10)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg5', + sender: '0x3333333333333333333333333333333333333333333333333333333333333333', + senderName: 'Charlie Base', + content: 'S:🏕️:46.0549,14.5038:Staging area established, supplies available', + timestamp: now.subtract(const Duration(minutes: 8)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg6', + sender: '0x1111111111111111111111111111111111111111111111111111111111111111', + senderName: 'Alpha Team Lead', + content: 'S:🔥:46.0620,14.5120:Small campfire spotted in sector 4, monitoring', + timestamp: now.subtract(const Duration(minutes: 5)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg7', + sender: '0x2222222222222222222222222222222222222222222222222222222222222222', + senderName: 'Bravo Scout', + content: 'Patient stabilized, waiting for extraction', + timestamp: now.subtract(const Duration(minutes: 3)), + isSent: false, + isPublicChannel: false, + ), + Message( + id: 'msg8', + sender: 'self', + senderName: 'You', + content: 'All teams: Weather window closing in 2 hours, prepare to RTB', + timestamp: now.subtract(const Duration(minutes: 1)), + isSent: true, + isPublicChannel: true, + ), + ]; + } + + /// Generate mock SAR markers from messages + static List getMockSarMarkers() { + final now = DateTime.now(); + return [ + SarMarker( + id: 'sar1', + type: SarMarkerType.foundPerson, + latitude: 46.0589, + longitude: 14.5078, + message: 'Found injured hiker near trail marker 7', + timestamp: now.subtract(const Duration(minutes: 12)), + sender: '0x2222222222222222222222222222222222222222222222222222222222222222', + senderName: 'Bravo Scout', + ), + SarMarker( + id: 'sar2', + type: SarMarkerType.stagingArea, + latitude: 46.0549, + longitude: 14.5038, + message: 'Staging area established, supplies available', + timestamp: now.subtract(const Duration(minutes: 8)), + sender: '0x3333333333333333333333333333333333333333333333333333333333333333', + senderName: 'Charlie Base', + ), + SarMarker( + id: 'sar3', + type: SarMarkerType.fireLocation, + latitude: 46.0620, + longitude: 14.5120, + message: 'Small campfire spotted in sector 4, monitoring', + timestamp: now.subtract(const Duration(minutes: 5)), + sender: '0x1111111111111111111111111111111111111111111111111111111111111111', + senderName: 'Alpha Team Lead', + ), + ]; + } + + /// Mock device info for connection status + static Map getMockDeviceInfo() { + return { + 'deviceName': 'MeshCore-SAR-DEMO', + 'firmwareVersion': '2.1.0', + 'hardwareVersion': 'v3', + 'publicKey': '0xAABBCCDDEEFF00112233445566778899AABBCCDDEEFF00112233445566778899', + 'batteryMillivolts': 3950, + 'storageUsed': 1024 * 512, // 512 KB + 'storageTotal': 1024 * 1024 * 4, // 4 MB + }; + } + + /// Mock radio parameters + static Map getMockRadioParams() { + return { + 'frequency': 915.0, + 'bandwidth': 125.0, + 'spreadingFactor': 9, + 'codingRate': 7, + 'txPower': 20, + }; + } +} diff --git a/integration_test/helpers/screenshot_helper.dart b/integration_test/helpers/screenshot_helper.dart new file mode 100644 index 0000000..3409c03 --- /dev/null +++ b/integration_test/helpers/screenshot_helper.dart @@ -0,0 +1,116 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// Helper class for taking screenshots during integration tests +class ScreenshotHelper { + final IntegrationTestWidgetsFlutterBinding binding; + final String outputDir; + int _screenshotCounter = 0; + + ScreenshotHelper(this.binding, {this.outputDir = 'screenshots'}); + + /// Take a screenshot with automatic numbering and description + Future takeScreenshot( + WidgetTester tester, + String description, { + Duration? wait, + }) async { + // Wait for UI to settle + await tester.pumpAndSettle(wait ?? const Duration(milliseconds: 500)); + + // Add extra delay for animations + await Future.delayed(const Duration(milliseconds: 300)); + + // Increment counter + _screenshotCounter++; + + // Format filename: 01_description.png + final filename = + '${_screenshotCounter.toString().padLeft(2, '0')}_${_sanitizeFilename(description)}.png'; + + if (kDebugMode) { + print('📸 Taking screenshot: $filename'); + } + + // Take screenshot + await binding.takeScreenshot(filename); + } + + /// Sanitize filename by removing special characters + String _sanitizeFilename(String input) { + return input + .toLowerCase() + .replaceAll(RegExp(r'[^\w\s-]'), '') + .replaceAll(RegExp(r'[\s_]+'), '_') + .replaceAll(RegExp(r'^-+|-+$'), ''); + } + + /// Reset counter (useful for multiple test runs) + void resetCounter() { + _screenshotCounter = 0; + } + + /// Get current screenshot count + int get screenshotCount => _screenshotCounter; + + /// Create output directory if it doesn't exist + static Future ensureOutputDir(String path) async { + final dir = Directory(path); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + } +} + +/// Extension methods for easier screenshot taking +extension ScreenshotTestExtension on WidgetTester { + /// Wait for a specific widget to appear + Future waitFor( + Finder finder, { + Duration timeout = const Duration(seconds: 10), + }) async { + final end = DateTime.now().add(timeout); + while (DateTime.now().isBefore(end)) { + await pump(); + if (finder.evaluate().isNotEmpty) { + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + throw Exception('Widget not found: $finder'); + } + + /// Tap and wait for navigation + Future tapAndSettle(Finder finder, {Duration? settleDuration}) async { + await tap(finder); + await pumpAndSettle(settleDuration ?? const Duration(milliseconds: 500)); + } + + /// Scroll until widget is visible + Future scrollUntilVisible( + Finder finder, + Finder scrollable, { + double delta = 100, + int maxScrolls = 50, + }) async { + int scrollCount = 0; + while (finder.evaluate().isEmpty && scrollCount < maxScrolls) { + await drag(scrollable, Offset(0, -delta)); + await pump(const Duration(milliseconds: 100)); + scrollCount++; + } + if (finder.evaluate().isEmpty) { + throw Exception('Could not scroll to widget: $finder'); + } + } + + /// Fill text field and dismiss keyboard + Future enterTextAndDismiss(Finder finder, String text) async { + await enterText(finder, text); + await pump(); + await testTextInput.receiveAction(TextInputAction.done); + await pumpAndSettle(); + } +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d29e884..88ec83e 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -48,6 +48,8 @@ PODS: - geolocator_apple (1.2.0): - Flutter - FlutterMacOS + - integration_test (0.0.1): + - Flutter - ObjectBox (4.4.1) - objectbox_flutter_libs (0.0.1): - Flutter @@ -80,6 +82,7 @@ DEPENDENCIES: - flutter_compass (from `.symlinks/plugins/flutter_compass/ios`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - objectbox_flutter_libs (from `.symlinks/plugins/objectbox_flutter_libs/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) @@ -113,6 +116,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_local_notifications/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" objectbox_flutter_libs: :path: ".symlinks/plugins/objectbox_flutter_libs/ios" package_info_plus: @@ -139,6 +144,7 @@ SPEC CHECKSUMS: flutter_compass: b236ab69b61545cce89fd58527f401a7587d5cc1 flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e ObjectBox: 7da4aceb5013d041bfafdbc6d744a26918b09757 objectbox_flutter_libs: 09b1dec1b4cd27bf1a5f9bae7ccaa7e43588bf31 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 diff --git a/ios/Runner.app.dSYM.zip b/ios/Runner.app.dSYM.zip index 7529a0e..1d07b85 100644 Binary files a/ios/Runner.app.dSYM.zip and b/ios/Runner.app.dSYM.zip differ diff --git a/ios/Runner.ipa b/ios/Runner.ipa index f05bd0e..58fd4f6 100644 Binary files a/ios/Runner.ipa and b/ios/Runner.ipa differ diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 2b895d6..d95b2b7 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -278,10 +278,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -333,10 +337,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -489,7 +497,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -511,7 +519,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -530,7 +538,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -547,7 +555,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -679,7 +687,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -702,7 +710,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = 24; + CURRENT_PROJECT_VERSION = 27; DEVELOPMENT_TEAM = JND55328G8; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8485c86..626e74d 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -12,6 +12,22 @@ $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName MeshCore SAR + CFBundleDocumentTypes + + + CFBundleTypeName + MBTiles Map File + CFBundleTypeRole + Viewer + LSHandlerRank + Owner + LSItemContentTypes + + public.database + public.data + + + CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -27,7 +43,7 @@ CFBundleSignature ???? CFBundleVersion - 24 + 27 LSRequiresIPhoneOS NSBluetoothAlwaysUsageDescription @@ -49,10 +65,10 @@ UIBackgroundModes location - fetch + bluetooth-central processing external-accessory - bluetooth-central + fetch UILaunchStoryboardName LaunchScreen @@ -71,6 +87,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UISupportsDocumentBrowser + UIUserNotificationSettings UIUserNotificationTypesEnabled @@ -80,36 +98,18 @@ UIUserNotificationTypeSound - UISupportsDocumentBrowser - - CFBundleDocumentTypes - - - CFBundleTypeName - MBTiles Map File - LSHandlerRank - Owner - LSItemContentTypes - - public.database - public.data - - CFBundleTypeRole - Viewer - - UTImportedTypeDeclarations - UTTypeIdentifier - com.mapbox.mbtiles - UTTypeDescription - MBTiles Map Archive UTTypeConformsTo public.database public.data + UTTypeDescription + MBTiles Map Archive + UTTypeIdentifier + com.mapbox.mbtiles UTTypeTagSpecification public.filename-extension diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index 7208a61..e729032 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,19 +5,24 @@ - + - + - + - + + + + + + diff --git a/lib/models/message.dart b/lib/models/message.dart index a145804..8cfdaf8 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -64,6 +64,11 @@ class Message { final DateTime? deliveredAt; // When delivery was confirmed final Uint8List? recipientPublicKey; // Full 32-byte public key of recipient (for retry) + // Retry tracking (for automatic retry with progressive timeouts) + final int retryAttempt; // Current retry attempt (0-3), 0 = first send + final DateTime? lastRetryAt; // When last retry was sent + final bool usedFloodFallback; // Whether message fell back to flood mode after retries + // Read status tracking final bool isRead; // Whether message has been read by user @@ -88,6 +93,9 @@ class Message { this.roundTripTimeMs, this.deliveredAt, this.recipientPublicKey, + this.retryAttempt = 0, + this.lastRetryAt, + this.usedFloodFallback = false, this.isRead = false, }); @@ -172,16 +180,38 @@ class Message { String get deliveryStatusText { switch (deliveryStatus) { case MessageDeliveryStatus.sending: + if (retryAttempt > 0) { + return 'Retrying ($retryAttempt/3)...'; + } return 'Sending...'; + case MessageDeliveryStatus.sent: + if (retryAttempt > 0) { + return 'Sent (retry $retryAttempt)'; + } return 'Sent'; + case MessageDeliveryStatus.delivered: - if (roundTripTimeMs != null) { - return 'Delivered (${roundTripTimeMs}ms)'; + final rttText = roundTripTimeMs != null ? '${roundTripTimeMs}ms' : ''; + if (retryAttempt > 0 && rttText.isNotEmpty) { + return 'Delivered ($rttText) [retry $retryAttempt]'; + } else if (retryAttempt > 0) { + return 'Delivered [retry $retryAttempt]'; + } else if (rttText.isNotEmpty) { + return 'Delivered ($rttText)'; } return 'Delivered'; + case MessageDeliveryStatus.failed: + if (usedFloodFallback) { + return 'Failed (tried flood)'; + } + if (retryAttempt > 0) { + final retryWord = retryAttempt == 1 ? 'retry' : 'retries'; + return 'Failed (after $retryAttempt $retryWord)'; + } return 'Failed'; + case MessageDeliveryStatus.received: return ''; } @@ -229,6 +259,9 @@ class Message { int? roundTripTimeMs, DateTime? deliveredAt, Uint8List? recipientPublicKey, + int? retryAttempt, + DateTime? lastRetryAt, + bool? usedFloodFallback, bool? isRead, }) { return Message( @@ -252,6 +285,9 @@ class Message { roundTripTimeMs: roundTripTimeMs ?? this.roundTripTimeMs, deliveredAt: deliveredAt ?? this.deliveredAt, recipientPublicKey: recipientPublicKey ?? this.recipientPublicKey, + retryAttempt: retryAttempt ?? this.retryAttempt, + lastRetryAt: lastRetryAt ?? this.lastRetryAt, + usedFloodFallback: usedFloodFallback ?? this.usedFloodFallback, isRead: isRead ?? this.isRead, ); } diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 0e2dee0..74bcde8 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -198,6 +198,23 @@ class AppProvider with ChangeNotifier { debugPrint('✅ [AppProvider] Message delivered - ACK: $ackCode, RTT: ${roundTripTimeMs}ms'); messagesProvider.markMessageDelivered(ackCode, roundTripTimeMs); }; + + // Wire up MessagesProvider's sendMessageCallback for retry logic + messagesProvider.sendMessageCallback = ({ + required contactPublicKey, + required text, + required messageId, + required contact, + retryAttempt = 0, + }) async { + return await connectionProvider.sendTextMessage( + contactPublicKey: contactPublicKey, + text: text, + messageId: messageId, + contact: contact, + retryAttempt: retryAttempt, + ); + }; } /// Initialize the app (load contacts, sync time, etc.) diff --git a/lib/providers/connection_provider.dart b/lib/providers/connection_provider.dart index 18aa012..af157b3 100644 --- a/lib/providers/connection_provider.dart +++ b/lib/providers/connection_provider.dart @@ -11,6 +11,23 @@ import 'helpers/room_login_manager.dart'; import 'helpers/message_delivery_tracker.dart'; import 'helpers/ping_tracker.dart'; +/// Pending send operation for auto-recovery +class _PendingSendOperation { + final Uint8List contactPublicKey; + final String text; + final String? messageId; + final Contact? contact; + final int retryAttempt; + + _PendingSendOperation({ + required this.contactPublicKey, + required this.text, + this.messageId, + this.contact, + this.retryAttempt = 0, + }); +} + /// Result of a ping (telemetry request) operation class PingResult { final bool success; @@ -117,6 +134,9 @@ class ConnectionProvider with ChangeNotifier { Function(int ackCode, int roundTripTimeMs)? onMessageDelivered; Function(Uint8List publicKeyPrefix, Uint8List statusData)? onStatusResponse; + // Track pending send operations for auto-recovery + final Map _pendingSendOperations = {}; + ConnectionProvider() { _initializeBleService(); } @@ -147,8 +167,9 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); }; - _bleService.onError = (error) { + _bleService.onError = (error, {int? errorCode}) { print('⚠️ [Provider] BLE error received: $error'); + print(' Error code: ${errorCode ?? "none"}'); print(' Current connection state: ${_deviceInfo.connectionState}'); _error = error; @@ -169,6 +190,57 @@ class ConnectionProvider with ChangeNotifier { notifyListeners(); }; + _bleService.onContactNotFound = (contactPublicKey) async { + print('🔧 [Provider] Contact not found error detected - initiating auto-recovery'); + + if (contactPublicKey == null) { + print(' ⚠️ No contact public key available for recovery'); + return; + } + + // Generate operation ID from public key + final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + final pendingOp = _pendingSendOperations[operationId]; + + if (pendingOp == null || pendingOp.contact == null) { + print(' ⚠️ No pending operation found for recovery: $operationId'); + return; + } + + print(' 📋 Found pending operation for: ${pendingOp.contact!.advName}'); + print(' 📤 Step 1: Adding contact to radio...'); + + try { + // Step 1: Add the contact to the radio + await _bleService.addOrUpdateContact(pendingOp.contact!); + + // Small delay to ensure contact is added before retrying + await Future.delayed(const Duration(milliseconds: 300)); + + print(' ✅ Contact added successfully'); + print(' 🔄 Step 2: Retrying message send...'); + + // Step 2: Retry the send operation + await _bleService.sendTextMessage( + contactPublicKey: pendingOp.contactPublicKey, + text: pendingOp.text, + attempt: pendingOp.retryAttempt, + ); + + print(' ✅ Auto-recovery completed - message resent'); + + // Clear pending operation after successful recovery + _pendingSendOperations.remove(operationId); + } catch (e) { + print(' ❌ Auto-recovery failed: $e'); + _error = 'Auto-recovery failed: $e'; + notifyListeners(); + + // Clear pending operation after failed recovery + _pendingSendOperations.remove(operationId); + } + }; + _bleService.onContactReceived = (contact) { onContactReceived?.call(contact); }; @@ -529,6 +601,7 @@ class ConnectionProvider with ChangeNotifier { _roomLoginManager .clearRoomLoginStates(); // Clear login states on disconnect _pingTracker.clearAll(); // Clear pending pings on disconnect + _pendingSendOperations.clear(); // Clear pending operations on disconnect notifyListeners(); } @@ -582,11 +655,13 @@ class ConnectionProvider with ChangeNotifier { /// /// [messageId] - optional message ID to track delivery status /// [contact] - optional contact object for path status logging + /// [retryAttempt] - retry attempt number (0 = first send, 1-3 = retries) Future sendTextMessage({ required Uint8List contactPublicKey, required String text, String? messageId, Contact? contact, + int retryAttempt = 0, }) async { if (!_bleService.isConnected) { _error = 'Not connected to device'; @@ -595,9 +670,13 @@ class ConnectionProvider with ChangeNotifier { } try { - // Log path status if contact info is available + // Log path status and retry info if (contact != null) { - print('📤 [ConnectionProvider] Sending message to ${contact.advName}'); + if (retryAttempt > 0) { + print('🔄 [ConnectionProvider] Sending message to ${contact.advName} (retry $retryAttempt/3)'); + } else { + print('📤 [ConnectionProvider] Sending message to ${contact.advName}'); + } print(' Type: ${contact.type.displayName}'); print(' Path status: ${contact.pathDescription}'); if (contact.hasPath) { @@ -605,6 +684,21 @@ class ConnectionProvider with ChangeNotifier { } else { print(' ⚠️ No path available - will use flood mode'); } + } else if (retryAttempt > 0) { + print('🔄 [ConnectionProvider] Sending message (retry $retryAttempt/3)'); + } + + // Track pending operation for auto-recovery (if contact not found in radio) + if (contact != null) { + final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + _pendingSendOperations[operationId] = _PendingSendOperation( + contactPublicKey: contactPublicKey, + text: text, + messageId: messageId, + contact: contact, + retryAttempt: retryAttempt, + ); + print(' 📝 Tracked pending operation for auto-recovery: $operationId'); } // IMPORTANT: Track pending message BEFORE sending to avoid race condition @@ -615,12 +709,23 @@ class ConnectionProvider with ChangeNotifier { print(' Added message ID to pending queue BEFORE sending: $messageId'); } - // Send the message + // Send the message with retry attempt info await _bleService.sendTextMessage( contactPublicKey: contactPublicKey, text: text, + attempt: retryAttempt, ); + // Clear pending operation after successful send (no error) + // If ERR_CODE_NOT_FOUND occurs, the operation will be recovered automatically + if (contact != null) { + final operationId = contactPublicKey.sublist(0, 6).map((b) => b.toRadixString(16).padLeft(2, '0')).join(':'); + // Use a small delay to allow error response to arrive before clearing + Future.delayed(const Duration(milliseconds: 500), () { + _pendingSendOperations.remove(operationId); + }); + } + return true; } catch (e) { _error = 'Failed to send message: $e'; diff --git a/lib/providers/helpers/message_retry_manager.dart b/lib/providers/helpers/message_retry_manager.dart new file mode 100644 index 0000000..ceb70a8 --- /dev/null +++ b/lib/providers/helpers/message_retry_manager.dart @@ -0,0 +1,89 @@ +import '../../models/message.dart'; +import '../../models/contact.dart'; + +/// Manages message retry state and logic +/// +/// This helper class centralizes retry logic for direct messages, implementing +/// a progressive timeout strategy (4s, 8s, 12s) for messages sent to contacts +/// with learned routing paths. +class MessageRetryManager { + // Track retry state for each message ID + final Map _retryAttempts = {}; + final Map _lastRetryTimes = {}; + + // Progressive timeout values in milliseconds + static const List _timeouts = [4000, 8000, 12000]; + + /// Get timeout for a specific retry attempt (0-2) + /// Returns: 4000ms for attempt 0, 8000ms for attempt 1, 12000ms for attempt 2 + int getTimeoutForAttempt(int attempt) { + if (attempt < 0 || attempt >= _timeouts.length) { + return _timeouts.last; // Default to last timeout if out of range + } + return _timeouts[attempt]; + } + + /// Check if a message is eligible for retry + /// + /// Returns true if: + /// - The message has retryAttempt < 3 + /// - The contact has a learned path (contact.hasPath == true) + /// - The message hasn't used flood fallback yet + /// + /// Messages to contacts without paths should NOT retry (flood mode already broadcasts) + bool canRetry(Message message, Contact contact) { + // Never retry if already tried flood mode + if (message.usedFloodFallback) { + return false; + } + + // Never retry beyond 3 attempts + if (message.retryAttempt >= 3) { + return false; + } + + // Only retry if contact has a learned path + // If no path, the device uses flood mode automatically - retrying won't help + return contact.hasPath; + } + + /// Check if should fall back to flood mode + /// + /// Returns true if: + /// - Message has exhausted all 3 retry attempts + /// - Contact still has no path + /// - Hasn't already used flood fallback + bool shouldUseFloodFallback(Message message, Contact contact) { + return message.retryAttempt >= 3 && + !contact.hasPath && + !message.usedFloodFallback; + } + + /// Track a retry attempt for a message + void trackRetry(String messageId, int attempt) { + _retryAttempts[messageId] = attempt; + _lastRetryTimes[messageId] = DateTime.now(); + } + + /// Clear retry tracking for a message (on success or permanent failure) + void clearRetry(String messageId) { + _retryAttempts.remove(messageId); + _lastRetryTimes.remove(messageId); + } + + /// Clear all retry tracking (on disconnect) + void clearAll() { + _retryAttempts.clear(); + _lastRetryTimes.clear(); + } + + /// Get current retry attempt for a message (for debugging) + int? getRetryAttempt(String messageId) { + return _retryAttempts[messageId]; + } + + /// Get last retry time for a message (for debugging) + DateTime? getLastRetryTime(String messageId) { + return _lastRetryTimes[messageId]; + } +} diff --git a/lib/providers/messages_provider.dart b/lib/providers/messages_provider.dart index 0ff7997..e62fdf0 100644 --- a/lib/providers/messages_provider.dart +++ b/lib/providers/messages_provider.dart @@ -2,11 +2,13 @@ import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import '../models/message.dart'; +import '../models/contact.dart'; import '../models/sar_marker.dart'; import '../services/message_storage_service.dart'; import '../services/notification_service.dart'; import '../utils/sar_message_parser.dart'; import '../l10n/app_localizations.dart'; +import 'helpers/message_retry_manager.dart'; /// Messages Provider - manages message history and SAR markers class MessagesProvider with ChangeNotifier { @@ -23,6 +25,21 @@ class MessagesProvider with ChangeNotifier { // Track timeout timers for pending messages final Map _timeoutTimers = {}; + // Retry management + final MessageRetryManager _retryManager = MessageRetryManager(); + + // Track which contact each sent message was sent to (for retry logic) + final Map _messageContactMap = {}; + + // Callback to connection provider for sending messages (set by AppProvider) + Future Function({ + required Uint8List contactPublicKey, + required String text, + required String messageId, + required Contact contact, + int retryAttempt, + })? sendMessageCallback; + List get messages => List.unmodifiable(_messages); List get contactMessages => @@ -176,7 +193,16 @@ class MessagesProvider with ChangeNotifier { /// 2. Same channel index (for channel messages) /// 3. Same sender timestamp /// 4. Same text content + /// + /// Note: Sent messages (isSentMessage=true) are NEVER duplicates + /// because they can be retried with different message IDs bool _isDuplicate(Message message) { + // Sent messages (our own messages) should never be considered duplicates + // They can be retried multiple times with different IDs + if (message.isSentMessage) { + return false; + } + return _messages.any((existing) { // Check message type matches if (existing.messageType != message.messageType) { @@ -468,7 +494,7 @@ class MessagesProvider with ChangeNotifier { } /// Add a sent message with initial status - void addSentMessage(Message message) { + void addSentMessage(Message message, {Contact? contact}) { print('📝 [MessagesProvider] addSentMessage called'); print(' Message ID: ${message.id}'); print(' Message type: ${message.messageType}'); @@ -493,6 +519,12 @@ class MessagesProvider with ChangeNotifier { print(' ✅ Message added to list at index ${_messages.length - 1}'); print(' Total messages in list: ${_messages.length}'); + // Store contact mapping for retry logic + if (contact != null) { + _messageContactMap[message.id] = contact; + print(' ✅ Stored contact mapping for retry logic'); + } + // If it's a SAR marker message, extract and store the marker if (sendingMessage.isSarMarker) { final marker = sendingMessage.toSarMarker(); @@ -599,6 +631,9 @@ class MessagesProvider with ChangeNotifier { // Remove from pending _pendingSentMessages.remove(ackCode); + // Clear retry tracking on successful delivery + _retryManager.clearRetry(message.id); + print('✅ [MessagesProvider] Message ${message.id} delivered in ${roundTripTimeMs}ms (ACK $ackCode)'); print(' Updated status to: ${updatedMessage.deliveryStatus}'); print(' Calling notifyListeners() to update UI'); @@ -640,15 +675,130 @@ class MessagesProvider with ChangeNotifier { } } - /// Update message status to failed + /// Update message status to failed (with retry logic) void markMessageFailed(String messageId) { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + print('⚠️ [MessagesProvider] markMessageFailed: Message not found: $messageId'); + return; + } + + final message = _messages[index]; + final contact = _messageContactMap[messageId]; + + print('❌ [MessagesProvider] Message $messageId timeout/failed'); + print(' Retry attempt: ${message.retryAttempt}'); + print(' Contact has path: ${contact?.hasPath ?? false}'); + print(' Used flood fallback: ${message.usedFloodFallback}'); + + // Decision tree for retry/flood/fail + if (contact != null && _retryManager.canRetry(message, contact)) { + // RETRY: Contact has path and retry attempts < 3 + _scheduleRetry(messageId, message, contact); + } else if (contact != null && _retryManager.shouldUseFloodFallback(message, contact)) { + // FLOOD FALLBACK: After 3 retries failed, try flood once + _sendWithFloodMode(messageId, message, contact); + } else { + // PERMANENTLY FAILED: No retry possible + _markAsPermanentlyFailed(messageId, message); + } + } + + /// Schedule a retry with progressive timeout + void _scheduleRetry(String messageId, Message message, Contact contact) { + final nextAttempt = message.retryAttempt + 1; + final timeout = _retryManager.getTimeoutForAttempt(message.retryAttempt); + + print('🔄 [MessagesProvider] Scheduling retry $nextAttempt/3 for message $messageId'); + print(' Timeout: ${timeout}ms'); + + // Update message with new retry attempt final index = _messages.indexWhere((m) => m.id == messageId); if (index != -1) { - final message = _messages[index]; - final updatedMessage = message.copyWith( + _messages[index] = message.copyWith( + retryAttempt: nextAttempt, + deliveryStatus: MessageDeliveryStatus.sending, + lastRetryAt: DateTime.now(), + ); + + // Cancel old timeout timer + if (message.expectedAckTag != null) { + _timeoutTimers[message.expectedAckTag]?.cancel(); + _timeoutTimers.remove(message.expectedAckTag); + _pendingSentMessages.remove(message.expectedAckTag); + } + + // Track retry + _retryManager.trackRetry(messageId, nextAttempt); + + notifyListeners(); // Update UI to show "Retrying (X/3)..." + + // Schedule actual retry after delay + Timer(Duration(milliseconds: timeout), () async { + print('⏰ [MessagesProvider] Executing retry $nextAttempt for message $messageId'); + if (sendMessageCallback != null) { + await sendMessageCallback!( + contactPublicKey: contact.publicKey, + text: message.text, + messageId: messageId, + contact: contact, + retryAttempt: nextAttempt, + ); + } else { + print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot retry'); + } + }); + + _persistMessages(); + } + } + + /// Send message with flood mode as last resort + Future _sendWithFloodMode(String messageId, Message message, Contact contact) async { + print('🌊 [MessagesProvider] Trying flood mode for message $messageId'); + + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + _messages[index] = message.copyWith( + usedFloodFallback: true, + deliveryStatus: MessageDeliveryStatus.sending, + ); + + // Cancel old timeout timer + if (message.expectedAckTag != null) { + _timeoutTimers[message.expectedAckTag]?.cancel(); + _timeoutTimers.remove(message.expectedAckTag); + _pendingSentMessages.remove(message.expectedAckTag); + } + + notifyListeners(); + + // Send with flood mode (no retry after this) + if (sendMessageCallback != null) { + await sendMessageCallback!( + contactPublicKey: contact.publicKey, + text: message.text, + messageId: messageId, + contact: contact, + retryAttempt: 0, // Reset attempt for flood + ); + } else { + print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot send flood'); + } + + _persistMessages(); + } + } + + /// Mark message as permanently failed + void _markAsPermanentlyFailed(String messageId, Message message) { + print('❌ [MessagesProvider] Message $messageId permanently failed'); + + final index = _messages.indexWhere((m) => m.id == messageId); + if (index != -1) { + _messages[index] = message.copyWith( deliveryStatus: MessageDeliveryStatus.failed, ); - _messages[index] = updatedMessage; // Cancel timeout timer if it exists if (message.expectedAckTag != null) { @@ -657,13 +807,61 @@ class MessagesProvider with ChangeNotifier { _pendingSentMessages.remove(message.expectedAckTag); } - print('❌ [MessagesProvider] Message $messageId marked as failed'); + // Clear retry tracking + _retryManager.clearRetry(messageId); _persistMessages(); notifyListeners(); } } + /// Resend a failed message + Future resendMessage(String messageId) async { + final index = _messages.indexWhere((m) => m.id == messageId); + if (index == -1) { + print('⚠️ [MessagesProvider] resendMessage: Message not found: $messageId'); + return; + } + + final message = _messages[index]; + final contact = _messageContactMap[messageId]; + + if (contact == null) { + print('⚠️ [MessagesProvider] Cannot resend: Contact not found for message $messageId'); + return; + } + + print('🔁 [MessagesProvider] Resending message $messageId'); + + // Reset retry state + _messages[index] = message.copyWith( + retryAttempt: 0, + usedFloodFallback: false, + deliveryStatus: MessageDeliveryStatus.sending, + lastRetryAt: DateTime.now(), + ); + + // Clear retry tracking + _retryManager.clearRetry(messageId); + + notifyListeners(); + + // Send again + if (sendMessageCallback != null) { + await sendMessageCallback!( + contactPublicKey: contact.publicKey, + text: message.text, + messageId: messageId, + contact: contact, + retryAttempt: 0, + ); + } else { + print('⚠️ [MessagesProvider] sendMessageCallback not set, cannot resend'); + } + + _persistMessages(); + } + @override void dispose() { // Cancel all pending timeout timers @@ -671,6 +869,10 @@ class MessagesProvider with ChangeNotifier { timer.cancel(); } _timeoutTimers.clear(); + + // Clear retry manager + _retryManager.clearAll(); + super.dispose(); } } diff --git a/lib/screens/device_config_screen.dart b/lib/screens/device_config_screen.dart index d569f76..074ccff 100644 --- a/lib/screens/device_config_screen.dart +++ b/lib/screens/device_config_screen.dart @@ -194,6 +194,9 @@ class _DeviceConfigScreenState extends State { ); } + // Refetch device info to update UI with new settings + await connectionProvider.refreshDeviceInfo(); + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -264,6 +267,9 @@ class _DeviceConfigScreenState extends State { // Save TX power await connectionProvider.setTxPower(txPowerResult.value!); + // Refetch device info to update UI with new settings + await connectionProvider.refreshDeviceInfo(); + if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index f5b56be..16a75bf 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -52,7 +52,8 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); final TileCacheService _tileCache = TileCacheService(); - final LocationTrackingService _locationService = LocationTrackingService(); + // DO NOT create a new LocationTrackingService instance here + // Use the singleton from AppProvider instead via _locationService getter final MapMarkerService _markerService = MapMarkerService(); bool _isInitialized = false; bool _isMapReady = false; // Track when map widget is actually rendered @@ -90,13 +91,16 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { @override bool get wantKeepAlive => true; + // Access the singleton LocationTrackingService from AppProvider + LocationTrackingService get _locationService => LocationTrackingService(); + @override void initState() { super.initState(); _loadSettings(); _loadMbtilesLayers(); _initializeTileCache(); - _initLocationTracking(); + _setupLocationCallbacks(); _startCompassTracking(); // Listen to map provider for navigation requests @@ -113,16 +117,22 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { }); } - Future _initLocationTracking() async { - // Initialize LocationTrackingService - final appProvider = context.read(); - await _locationService.initialize(appProvider.connectionProvider.bleService); + /// Setup location tracking callbacks for map-specific features + /// Note: LocationTrackingService is initialized and started by AppProvider + /// This method only adds map-specific callbacks for rotation and UI updates + void _setupLocationCallbacks() { + // Store the original callback from AppProvider + final originalCallback = _locationService.onPositionUpdate; - // Set up callbacks + // Add map-specific callback that chains with the original _locationService.onPositionUpdate = (position) { + // Call original callback first (AppProvider's logging) + originalCallback?.call(position); + + // Then handle map-specific logic if (mounted) { setState(() { - // Position updates are now handled by the service + // Position updates trigger UI rebuild for markers }); // Rotate map if rotation mode is enabled and heading is available @@ -140,16 +150,6 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { } } }; - - _locationService.onError = (error) { - debugPrint('Location tracking error: $error'); - }; - - // Request permissions and start tracking - final hasPermission = await _locationService.requestPermissions(); - if (hasPermission) { - await _locationService.startTracking(distanceThreshold: _gpsUpdateDistance); - } } void _startCompassTracking() { @@ -372,7 +372,9 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { final mapProvider = context.read(); mapProvider.removeListener(_handleMapNavigation); _compassStreamSubscription?.cancel(); - _locationService.stopTracking(); + // DO NOT stop location tracking - it's managed by AppProvider + // Just clear the map-specific callback + _locationService.onPositionUpdate = null; _mapController.dispose(); _tileCache.dispose(); super.dispose(); diff --git a/lib/services/ble/ble_response_handler.dart b/lib/services/ble/ble_response_handler.dart index d936ced..ca33f55 100644 --- a/lib/services/ble/ble_response_handler.dart +++ b/lib/services/ble/ble_response_handler.dart @@ -29,7 +29,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); -typedef OnErrorCallback = void Function(String error); +typedef OnErrorCallback = void Function(String error, {int? errorCode}); +typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); /// Processes incoming responses from the BLE device class BleResponseHandler { @@ -58,8 +59,12 @@ class BleResponseHandler { OnBinaryResponseCallback? onBinaryResponse; OnBatteryAndStorageCallback? onBatteryAndStorage; OnErrorCallback? onError; + OnContactNotFoundCallback? onContactNotFound; VoidCallback? onRxActivity; + // Track the last command that was sent, so we can retry if it fails with ERR_CODE_NOT_FOUND + Uint8List? _lastContactPublicKey; + // Getters int get rxPacketCount => _rxPacketCount; List get packetLogs => List.unmodifiable(_packetLogs); @@ -574,13 +579,25 @@ class BleResponseHandler { if (errorCode != null) { final errorMsg = FrameParser.getErrorMessage(errorCode); print(' ❌ [Error] $errorMsg'); - onError?.call(errorMsg); + + // Special handling for ERR_CODE_NOT_FOUND (2) - contact not in radio + if (errorCode == 2) { // ERR_CODE_NOT_FOUND + print(' ⚠️ [Error] Contact not found in radio - attempting auto-recovery'); + onContactNotFound?.call(_lastContactPublicKey); + } + + onError?.call(errorMsg, errorCode: errorCode); } } catch (e) { print(' ❌ [Error] Parsing error: $e'); } } + /// Track the last contact public key for retry logic + void setLastContactPublicKey(Uint8List? publicKey) { + _lastContactPublicKey = publicKey; + } + /// Log a packet void _logPacket(Uint8List data, PacketDirection direction, {int? responseCode}) { _packetLogs.add(BlePacketLog( diff --git a/lib/services/location_tracking_service.dart b/lib/services/location_tracking_service.dart index 9ef523a..010083a 100644 --- a/lib/services/location_tracking_service.dart +++ b/lib/services/location_tracking_service.dart @@ -77,6 +77,9 @@ class LocationTrackingService { /// Whether service has been initialized with BLE service bool _isInitialized = false; + /// Whether the first stable position has been set (without broadcast) + bool _firstPositionSet = false; + // ============================================================================ // Private Properties // ============================================================================ @@ -172,22 +175,52 @@ class LocationTrackingService { /// Get current GPS position /// /// Returns null if position unavailable or permissions denied. - Future getCurrentPosition() async { - try { - final position = await Geolocator.getCurrentPosition( - locationSettings: const LocationSettings( - accuracy: LocationAccuracy.best, - timeLimit: Duration(seconds: 5), - ), - ); + /// [timeLimit] - Maximum time to wait for position (default: 15 seconds) + /// [retryCount] - Number of retry attempts (default: 2) + Future getCurrentPosition({ + Duration timeLimit = const Duration(seconds: 15), + int retryCount = 2, + }) async { + for (int attempt = 0; attempt <= retryCount; attempt++) { + try { + if (attempt > 0) { + debugPrint('🔄 [LocationTracking] Retry attempt $attempt/$retryCount'); + // Exponential backoff: wait 2^attempt seconds before retry + await Future.delayed(Duration(seconds: 1 << attempt)); + } - currentPosition = position; - return position; - } catch (e) { - debugPrint('❌ [LocationTracking] Error getting position: $e'); - onError?.call('Failed to get current position: $e'); - return null; + final position = await Geolocator.getCurrentPosition( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.best, + timeLimit: timeLimit, + ), + ); + + currentPosition = position; + if (attempt > 0) { + debugPrint('✅ [LocationTracking] Position acquired after $attempt retries'); + } + return position; + } catch (e) { + final isLastAttempt = attempt == retryCount; + if (isLastAttempt) { + debugPrint('❌ [LocationTracking] Failed to get position after $retryCount retries: $e'); + // Only call error callback on final failure, and make it user-friendly + if (e.toString().contains('TimeoutException')) { + onError?.call('GPS signal weak. Position stream will continue trying...'); + } else { + onError?.call('Failed to get GPS position. Check device settings.'); + } + } else { + debugPrint('⚠️ [LocationTracking] Position attempt $attempt failed: $e'); + } + + if (isLastAttempt) { + return null; + } + } } + return null; } /// Get position stream with configurable distance filter @@ -211,6 +244,8 @@ class LocationTrackingService { /// [distanceThreshold] - GPS update distance filter /// /// Returns true if successful, false otherwise. + /// Note: This method returns immediately after starting the position stream. + /// Initial position acquisition happens asynchronously in the background. Future startTracking({double? distanceThreshold}) async { if (!_isInitialized || _bleService == null) { debugPrint( @@ -239,17 +274,28 @@ class LocationTrackingService { // Save settings await saveSettings(); - // Get initial position - await getCurrentPosition(); + // Try to get initial position in background (non-blocking) + // This will populate currentPosition but won't block tracking startup + getCurrentPosition( + timeLimit: const Duration(seconds: 10), + retryCount: 1, + ).then((position) { + if (position != null) { + debugPrint('✅ [LocationTracking] Initial position acquired in background'); + } + }).catchError((error) { + debugPrint('⚠️ [LocationTracking] Background initial position failed: $error'); + // Not critical - position stream will eventually provide position + }); - // Start position stream + // Start position stream immediately (don't wait for initial position) try { _positionSubscription = getPositionStream(distanceFilter: threshold) .listen( _handlePositionUpdate, onError: (error) { debugPrint('❌ [LocationTracking] Position stream error: $error'); - onError?.call('Position stream error: $error'); + onError?.call('GPS stream error. Retrying...'); }, ); @@ -259,10 +305,11 @@ class LocationTrackingService { debugPrint( '✅ [LocationTracking] Tracking started with ${threshold}m threshold', ); + debugPrint('📡 [LocationTracking] Waiting for GPS signal...'); return true; } catch (e) { debugPrint('❌ [LocationTracking] Failed to start tracking: $e'); - onError?.call('Failed to start tracking: $e'); + onError?.call('Failed to start GPS tracking: $e'); return false; } } @@ -277,6 +324,9 @@ class LocationTrackingService { isTracking = false; onTrackingStateChanged?.call(false); + // Reset first position flag so next connection starts fresh + _firstPositionSet = false; + // Save disabled state final prefs = await SharedPreferences.getInstance(); await prefs.setBool(_prefKeyEnabled, false); @@ -316,10 +366,57 @@ class LocationTrackingService { // Notify listeners onPositionUpdate?.call(position); + // SPECIAL CASE: First stable position after connection + // Set lat/lon on device WITHOUT broadcasting to mesh network + if (!_firstPositionSet) { + _setInitialPosition(position); + return; + } + // Check if we should broadcast to mesh network _checkAndBroadcast(position); } + /// Set initial position on device without broadcasting + /// + /// Called only for the first stable GPS position after connection starts. + /// Updates the device's advertised lat/lon but does NOT send an advertisement. + void _setInitialPosition(Position position) async { + if (_bleService == null || !_bleService!.isConnected) { + debugPrint('⚠️ [LocationTracking] Cannot set initial position: BLE not connected'); + return; + } + + try { + debugPrint('📍 [LocationTracking] Setting initial position (no broadcast)'); + + // Update device's advertised location WITHOUT sending advertisement + await _bleService!.setAdvertLatLon( + latitude: position.latitude, + longitude: position.longitude, + ); + + // Mark first position as set + _firstPositionSet = true; + + // Update last broadcast position to prevent immediate broadcast on next update + _lastBroadcastPosition = position; + _lastBroadcastTime = DateTime.now(); + + // Save to preferences + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_prefKeyLastLat, position.latitude); + await prefs.setDouble(_prefKeyLastLon, position.longitude); + + debugPrint('✅ [LocationTracking] Initial position set without broadcast'); + debugPrint(' Next broadcast allowed in ${minTimeIntervalSeconds}s'); + } catch (e) { + debugPrint('❌ [LocationTracking] Failed to set initial position: $e'); + onError?.call('Failed to set initial position: $e'); + // Don't mark as set on failure, so it will retry on next update + } + } + /// Check if position should be broadcast based on distance and time thresholds void _checkAndBroadcast(Position position) { // If never broadcast before, do it now diff --git a/lib/services/meshcore_ble_service.dart b/lib/services/meshcore_ble_service.dart index 093d440..7981306 100644 --- a/lib/services/meshcore_ble_service.dart +++ b/lib/services/meshcore_ble_service.dart @@ -28,7 +28,8 @@ typedef OnMessageDeliveredCallback = void Function(int ackCode, int roundTripTim typedef OnStatusResponseCallback = void Function(Uint8List publicKeyPrefix, Uint8List statusData); typedef OnBinaryResponseCallback = void Function(Uint8List publicKeyPrefix, int tag, Uint8List responseData); typedef OnBatteryAndStorageCallback = void Function(int millivolts, int? usedKb, int? totalKb); -typedef OnErrorCallback = void Function(String error); +typedef OnErrorCallback = void Function(String error, {int? errorCode}); +typedef OnContactNotFoundCallback = void Function(Uint8List? contactPublicKey); typedef OnConnectionStateCallback = void Function(bool isConnected); typedef OnReconnectionAttemptCallback = void Function(int attemptNumber, int maxAttempts); typedef OnRssiUpdateCallback = void Function(int rssi); @@ -62,6 +63,7 @@ class MeshCoreBleService { OnBinaryResponseCallback? onBinaryResponse; OnBatteryAndStorageCallback? onBatteryAndStorage; OnErrorCallback? onError; + OnContactNotFoundCallback? onContactNotFound; // Activity callbacks (for blinking indicators) VoidCallback? onRxActivity; @@ -149,8 +151,11 @@ class MeshCoreBleService { _responseHandler.onBatteryAndStorage = (millivolts, usedKb, totalKb) { onBatteryAndStorage?.call(millivolts, usedKb, totalKb); }; - _responseHandler.onError = (error) { - onError?.call(error); + _responseHandler.onError = (error, {int? errorCode}) { + onError?.call(error, errorCode: errorCode); + }; + _responseHandler.onContactNotFound = (contactPublicKey) { + onContactNotFound?.call(contactPublicKey); }; _responseHandler.onRxActivity = () { onRxActivity?.call(); @@ -247,6 +252,9 @@ class MeshCoreBleService { throw ArgumentError('Text message exceeds 160 character limit'); } + // Track the last contact for auto-recovery if contact not found + _responseHandler.setLastContactPublicKey(contactPublicKey); + await _commandSender.writeData(FrameBuilder.buildSendTxtMsg( contactPublicKey: contactPublicKey, text: text, diff --git a/lib/services/tile_cache_service.dart b/lib/services/tile_cache_service.dart index 2393b0e..8f447e3 100644 --- a/lib/services/tile_cache_service.dart +++ b/lib/services/tile_cache_service.dart @@ -47,7 +47,9 @@ class TileCacheService { FMTCTileProvider getTileProvider(MapLayer layer) { if (!_isInitialized) { - throw StateError('TileCacheService not initialized. Call initialize() first.'); + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); } return _store.getTileProvider( loadingStrategy: BrowseLoadingStrategy.cacheFirst, @@ -63,7 +65,9 @@ class TileCacheService { Function(double progress)? onProgress, }) async { if (!_isInitialized) { - throw StateError('TileCacheService not initialized. Call initialize() first.'); + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); } if (_isDownloading) { @@ -78,21 +82,19 @@ class TileCacheService { final downloadable = region.toDownloadable( minZoom: minZoom, maxZoom: maxZoom, - options: TileLayer( - urlTemplate: layer.urlTemplate, - ), + options: TileLayer(urlTemplate: layer.urlTemplate), ); - final download = _store.download.startForeground( - region: downloadable, - ); + final download = _store.download.startForeground(region: downloadable); await for (final progress in download.downloadProgress) { if (onProgress != null && progress.maxTilesCount > 0) { // Use attemptedTilesCount instead of successfulTilesCount // attemptedTilesCount includes successful + buffered + skipped tiles final percentage = progress.percentageProgress; - print('Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})'); + print( + 'Download progress: ${progress.attemptedTilesCount}/${progress.maxTilesCount} = ${percentage.toStringAsFixed(1)}% (successful: ${progress.successfulTilesCount}, buffered: ${progress.bufferedTilesCount}, skipped: ${progress.skippedTilesCount})', + ); onProgress(percentage); } } @@ -126,7 +128,9 @@ class TileCacheService { Future> getAvailableStores() async { if (!_isInitialized) { - throw StateError('TileCacheService not initialized. Call initialize() first.'); + throw StateError( + 'TileCacheService not initialized. Call initialize() first.', + ); } final stores = await FMTCRoot.stats.storesAvailable; @@ -137,11 +141,11 @@ class TileCacheService { if (!_isInitialized) return {}; final length = await _store.stats.length; - final size = await _store.stats.size; + final size = await _store.stats.all.then((a) => a.size); return { 'tileCount': length, - 'sizeMB': size / (1024 * 1024), + 'sizeMB': size / 1024, 'storeName': _storeName, }; } diff --git a/lib/widgets/contacts/direct_message_sheet.dart b/lib/widgets/contacts/direct_message_sheet.dart index 81bdcf2..e0c3f1c 100644 --- a/lib/widgets/contacts/direct_message_sheet.dart +++ b/lib/widgets/contacts/direct_message_sheet.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; +import 'package:geolocator/geolocator.dart'; import '../../models/contact.dart'; import '../../models/message.dart'; import '../../providers/connection_provider.dart'; @@ -43,6 +44,66 @@ class _DirectMessageSheetState extends State { }); } + /// Insert current GPS location at cursor position + Future _insertCurrentLocation() async { + try { + // Check location permission + LocationPermission permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + if (!mounted) return; + ToastLogger.error(context, 'Location permission denied'); + return; + } + } + + if (permission == LocationPermission.deniedForever) { + if (!mounted) return; + ToastLogger.error(context, 'Location permission permanently denied'); + return; + } + + // Get current position + final position = await Geolocator.getCurrentPosition( + desiredAccuracy: LocationAccuracy.best, + ); + + // Format location text + final locationText = '📍 Lat: ${position.latitude.toStringAsFixed(5)}, Lon: ${position.longitude.toStringAsFixed(5)}'; + + // Check if adding location would exceed limit + final currentText = _textController.text; + if (currentText.length + locationText.length > _maxCharacters) { + if (!mounted) return; + ToastLogger.error(context, 'Adding location would exceed 160 character limit'); + return; + } + + // Insert at cursor position or append + final selection = _textController.selection; + final newText = currentText.replaceRange( + selection.start >= 0 ? selection.start : currentText.length, + selection.end >= 0 ? selection.end : currentText.length, + locationText, + ); + + _textController.text = newText; + + // Move cursor to end of inserted text + final newCursorPosition = (selection.start >= 0 ? selection.start : currentText.length) + locationText.length; + _textController.selection = TextSelection.fromPosition( + TextPosition(offset: newCursorPosition), + ); + + if (!mounted) return; + ToastLogger.success(context, 'Location inserted'); + } catch (e) { + if (!mounted) return; + ToastLogger.error(context, 'Failed to get location: $e'); + } + } + Future _sendDirectMessage() async { final text = _textController.text.trim(); if (text.isEmpty) return; @@ -80,7 +141,8 @@ class _DirectMessageSheetState extends State { ); // Add to messages list with "sending" status - messagesProvider.addSentMessage(sentMessage); + // Pass contact for retry logic + messagesProvider.addSentMessage(sentMessage, contact: widget.contact); // Send direct message to contact (include contact for path logging) final sentSuccessfully = await connectionProvider.sendTextMessage( @@ -226,37 +288,64 @@ class _DirectMessageSheetState extends State { borderSide: BorderSide(color: colorScheme.primary, width: 2), ), contentPadding: const EdgeInsets.all(16), - counterText: _characterCount >= 150 - ? '$_characterCount/$_maxCharacters' - : '', - counterStyle: TextStyle( - fontSize: 11, - color: _characterCount > _maxCharacters * 0.9 - ? Colors.orange - : colorScheme.onSurfaceVariant, - ), + counterText: '', // Hide default counter ), textInputAction: TextInputAction.send, onSubmitted: (_) => _sendDirectMessage(), ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: _textController.text.trim().isEmpty - ? null - : _sendDirectMessage, - icon: const Icon(Icons.send), - label: Text(AppLocalizations.of(context)!.sendDirectMessage), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - backgroundColor: colorScheme.primary, - foregroundColor: colorScheme.onPrimary, - disabledBackgroundColor: colorScheme.surfaceContainerHighest, - disabledForegroundColor: colorScheme.onSurfaceVariant, - ), + // Always-visible character counter + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '$_characterCount / $_maxCharacters', + style: TextStyle( + fontSize: 12, + color: _characterCount > 155 + ? Colors.red + : (_characterCount > 140 + ? Colors.orange + : colorScheme.onSurfaceVariant), + fontWeight: _characterCount > 140 ? FontWeight.bold : FontWeight.normal, + ), + ), + ], ), ), + const SizedBox(height: 8), + // Location and Send buttons + Row( + children: [ + OutlinedButton.icon( + onPressed: _insertCurrentLocation, + icon: const Icon(Icons.my_location, size: 18), + label: const Text('Location'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + side: BorderSide(color: colorScheme.outline), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _textController.text.trim().isEmpty + ? null + : _sendDirectMessage, + icon: const Icon(Icons.send), + label: Text(AppLocalizations.of(context)!.sendDirectMessage), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + disabledBackgroundColor: colorScheme.surfaceContainerHighest, + disabledForegroundColor: colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), ], ), ), diff --git a/pubspec.lock b/pubspec.lock index 77265c1..9ff4baf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -294,6 +294,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.8.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_launcher_icons: dependency: "direct dev" description: @@ -373,6 +378,11 @@ packages: description: flutter source: sdk version: "0.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" geoclue: dependency: transitive description: @@ -477,6 +487,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.5.4" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -773,6 +788,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" proj4dart: dependency: transitive description: @@ -938,6 +961,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" synchronized: dependency: transitive description: @@ -1107,6 +1138,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 99db13c..1330c18 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -93,6 +93,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter # The "flutter_lints" package below contains a set of recommended lints to # encourage good coding practices. The lint set provided by the package is diff --git a/scripts/take_screenshots.sh b/scripts/take_screenshots.sh new file mode 100755 index 0000000..29b441e --- /dev/null +++ b/scripts/take_screenshots.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +# MeshCore SAR App Screenshot Script +# Captures screenshots on multiple devices for App Store submission + +set -e # Exit on error + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Configuration +OUTPUT_DIR="screenshots" +INTEGRATION_TEST="integration_test/app_screenshots_test.dart" + +# Device configurations for App Store screenshots +# iOS devices (required sizes: 6.7", 6.5", 5.5") +IOS_DEVICES=( + "iPhone Air" # 6.3" - 1206x2622 (newer large format) +) + +# Android devices (phone + tablet recommended) +ANDROID_DEVICES=( + "pixel_7_pro" # Phone - 1440x3120 + "pixel_tablet" # Tablet - 2560x1600 +) + +echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" +echo -e "${BLUE}║ MeshCore SAR Screenshot Generator ║${NC}" +echo -e "${BLUE}╔═══════════════════════════════════════════╗${NC}" +echo "" + +# Check if integration test exists +if [ ! -f "$INTEGRATION_TEST" ]; then + echo -e "${RED}❌ Error: Integration test not found at $INTEGRATION_TEST${NC}" + exit 1 +fi + +# Create output directory +mkdir -p "$OUTPUT_DIR" + +# Function to list available devices +list_devices() { + echo -e "${YELLOW}📱 Available iOS Simulators:${NC}" + xcrun simctl list devices available | grep "iPhone" | grep -v "unavailable" + echo "" + echo -e "${YELLOW}🤖 Available Android Emulators:${NC}" + emulator -list-avds + echo "" +} + +# Function to take screenshots on iOS +take_ios_screenshots() { + local device_name="$1" + echo -e "${GREEN}📸 Taking screenshots on iOS: $device_name${NC}" + + # Get device ID (UUID is the first parenthesized value) + local device_line=$(xcrun simctl list devices available | grep "$device_name" | grep -v "unavailable" | head -1) + local device_id=$(echo "$device_line" | sed -n 's/.*(\([0-9A-F-]*\)).*/\1/p') + + if [ -z "$device_id" ]; then + echo -e "${RED}❌ Device not found: $device_name${NC}" + echo -e "${YELLOW}💡 Creating simulator: $device_name${NC}" + # Try to create the device (this might fail if device type doesn't exist) + device_id=$(xcrun simctl create "$device_name" "$device_name" 2>&1) + if [ $? -ne 0 ]; then + echo -e "${RED}❌ Failed to create simulator. Skipping...${NC}" + return 1 + fi + fi + + echo -e "${BLUE} Device ID: $device_id${NC}" + + # Boot the simulator if not already booted + xcrun simctl boot "$device_id" 2>/dev/null || true + sleep 3 + + # Create device-specific output directory + local device_dir="$OUTPUT_DIR/ios/${device_name// /_}" + mkdir -p "$device_dir" + + # Run the integration test + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target="$INTEGRATION_TEST" \ + -d "$device_id" \ + --screenshot="$device_dir" || { + echo -e "${YELLOW}⚠️ Warning: Screenshot capture had issues on $device_name${NC}" + } + + echo -e "${GREEN}✅ Completed: $device_name${NC}" + echo "" +} + +# Function to take screenshots on Android +take_android_screenshots() { + local device_name="$1" + echo -e "${GREEN}📸 Taking screenshots on Android: $device_name${NC}" + + # Check if emulator exists + if ! emulator -list-avds | grep -q "^$device_name$"; then + echo -e "${RED}❌ Emulator not found: $device_name${NC}" + echo -e "${YELLOW}💡 Please create the emulator first using Android Studio${NC}" + return 1 + fi + + # Start emulator in background + echo -e "${BLUE} Starting emulator...${NC}" + emulator -avd "$device_name" -no-audio -no-boot-anim & + EMULATOR_PID=$! + + # Wait for emulator to boot + echo -e "${BLUE} Waiting for emulator to boot...${NC}" + adb wait-for-device + sleep 10 + + # Wait for boot to complete + while [ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]; do + echo -e "${BLUE} Still booting...${NC}" + sleep 3 + done + echo -e "${GREEN} Emulator booted${NC}" + + # Create device-specific output directory + local device_dir="$OUTPUT_DIR/android/${device_name}" + mkdir -p "$device_dir" + + # Run the integration test + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target="$INTEGRATION_TEST" \ + -d emulator-5554 \ + --screenshot="$device_dir" || { + echo -e "${YELLOW}⚠️ Warning: Screenshot capture had issues on $device_name${NC}" + } + + # Kill emulator + kill $EMULATOR_PID 2>/dev/null || true + + echo -e "${GREEN}✅ Completed: $device_name${NC}" + echo "" +} + +# Parse command line arguments +PLATFORM="all" +DEVICE_FILTER="" + +while [[ $# -gt 0 ]]; do + case $1 in + --ios) + PLATFORM="ios" + shift + ;; + --android) + PLATFORM="android" + shift + ;; + --device) + DEVICE_FILTER="$2" + shift 2 + ;; + --list) + list_devices + exit 0 + ;; + --help) + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --ios Take screenshots on iOS devices only" + echo " --android Take screenshots on Android devices only" + echo " --device Take screenshots on specific device only" + echo " --list List available devices" + echo " --help Show this help message" + echo "" + echo "Examples:" + echo " $0 # All devices" + echo " $0 --ios # iOS only" + echo " $0 --android # Android only" + echo " $0 --device 'iPhone 15 Pro Max' # Specific device" + echo " $0 --list # List available devices" + exit 0 + ;; + *) + echo -e "${RED}❌ Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac +done + +# Create test driver if it doesn't exist +DRIVER_FILE="test_driver/integration_test.dart" +mkdir -p test_driver +if [ ! -f "$DRIVER_FILE" ]; then + echo -e "${YELLOW}📝 Creating integration test driver...${NC}" + cat > "$DRIVER_FILE" << 'EOF' +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); +EOF +fi + +# Take screenshots +if [ -n "$DEVICE_FILTER" ]; then + # Specific device + echo -e "${BLUE}🎯 Taking screenshots on: $DEVICE_FILTER${NC}" + echo "" + + # Determine if iOS or Android based on device name + if [[ "$DEVICE_FILTER" == *"iPhone"* ]] || [[ "$DEVICE_FILTER" == *"iPad"* ]]; then + take_ios_screenshots "$DEVICE_FILTER" + else + take_android_screenshots "$DEVICE_FILTER" + fi +else + # Multiple devices based on platform + if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "ios" ]; then + echo -e "${BLUE}🍎 Taking iOS screenshots...${NC}" + echo "" + for device in "${IOS_DEVICES[@]}"; do + take_ios_screenshots "$device" + done + fi + + if [ "$PLATFORM" = "all" ] || [ "$PLATFORM" = "android" ]; then + echo -e "${BLUE}🤖 Taking Android screenshots...${NC}" + echo "" + for device in "${ANDROID_DEVICES[@]}"; do + take_android_screenshots "$device" + done + fi +fi + +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo -e "${GREEN}║ ✅ Screenshot Capture Complete! ║${NC}" +echo -e "${GREEN}╔═══════════════════════════════════════════╗${NC}" +echo "" +echo -e "${BLUE}📁 Screenshots saved to: $OUTPUT_DIR${NC}" +echo "" +echo -e "${YELLOW}Next steps:${NC}" +echo -e " 1. Review screenshots in $OUTPUT_DIR" +echo -e " 2. Organize by device size for App Store" +echo -e " 3. Add captions and localization if needed" +echo "" diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000..b38629c --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver();