diff --git a/TESTFLIGHT_GUIDE.md b/TESTFLIGHT_GUIDE.md new file mode 100644 index 0000000..b092678 --- /dev/null +++ b/TESTFLIGHT_GUIDE.md @@ -0,0 +1,244 @@ +# TestFlight and App Store Deployment Guide + +## Prerequisites + +- [x] Apple Developer Account ($99/year) - [developer.apple.com](https://developer.apple.com) +- [x] Xcode installed +- [x] Apple Transporter app installed +- [x] App icons ready (1024x1024px) +- [x] Bundle ID configured: `com.monitormx.meshcoreopen` + +## Step 1: Register Bundle Identifier + +1. Go to [Apple Developer - Identifiers](https://developer.apple.com/account/resources/identifiers/list) +2. Click the **"+"** button +3. Select **"App IDs"** → Continue +4. Select **"App"** → Continue +5. Fill in: + - **Description**: Meshcore Open + - **Bundle ID**: Explicit - `com.monitormx.meshcoreopen` + - **Capabilities**: Leave defaults (or add as needed) +6. Click **Continue** → **Register** + +## Step 2: Create App in App Store Connect + +1. Go to [App Store Connect](https://appstoreconnect.apple.com) +2. Sign in with your Apple ID +3. Click **"My Apps"** +4. Click the **"+"** button → **"New App"** +5. Fill in the form: + - **Platforms**: iOS + - **Name**: Meshcore Open + - **Primary Language**: English (U.S.) + - **Bundle ID**: Select `com.monitormx.meshcoreopen` from dropdown + - **SKU**: `meshcore-open-001` (or any unique identifier) + - **User Access**: Full Access +6. Click **"Create"** + +## Step 3: Build the IPA + +Run these commands from the project directory: + +```bash +# Add CocoaPods to PATH +export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" + +# Clean previous builds +../flutter/bin/flutter clean + +# Build IPA for App Store +../flutter/bin/flutter build ipa +``` + +The IPA will be created at: `build/ios/ipa/meshcore_open.ipa` + +## Step 4: Upload to App Store Connect via Transporter + +1. **Open Apple Transporter** + - Launch from Applications folder + - Sign in with your Apple ID + +2. **Upload the IPA** + - Drag and drop `build/ios/ipa/meshcore_open.ipa` into Transporter + - Click **"Deliver"** + - Wait for upload to complete (usually 1-5 minutes) + +3. **Processing** + - Apple will process your build (10-30 minutes) + - You'll receive an email when processing is complete + +## Step 5: Configure App Store Connect Metadata + +### App Information +1. In App Store Connect, go to your app +2. Fill in required information: + - **Subtitle**: Short description (30 chars max) + - **Privacy Policy URL**: Required for Bluetooth apps + - **Category**: Utilities or Productivity + - **Age Rating**: Complete questionnaire + +### App Store Listing +1. Go to **App Store** tab +2. Upload **Screenshots** (required): + - iPhone 6.7" display (1290 x 2796 pixels) - At least 1 screenshot + - iPhone 6.5" display (1242 x 2688 pixels) - At least 1 screenshot + - Optional: iPad screenshots + +3. Fill in **Description**: + ``` + Meshcore Open is a Flutter client for MeshCore LoRa mesh networking devices. + + Features: + - BLE connectivity to MeshCore devices + - Real-time mesh network communication + - Map visualization with OpenStreetMap + - Community management with QR code scanning + - Message tracking and retry system + + Connect to your MeshCore LoRa device and start communicating over the mesh network. + ``` + +4. **Keywords**: `lora,mesh,networking,bluetooth,communication` +5. **Support URL**: Your GitHub or website URL +6. **Marketing URL**: (Optional) + +### Version Information +1. **What's New in This Version**: + ``` + Initial release of Meshcore Open + + - BLE device connectivity + - Mesh network messaging + - Map integration + - Community features + ``` + +2. **Build**: Select the uploaded build once processing completes + +## Step 6: TestFlight Setup + +### Internal Testing (No Review Required) +1. Go to **TestFlight** tab in App Store Connect +2. Click **Internal Testing** → **"+"** to create a group +3. Name your group (e.g., "Internal Testers") +4. Add yourself as a tester using your email +5. Select the build you uploaded +6. Testers will receive an email with TestFlight invitation + +### External Testing (Requires Beta Review) +1. Click **External Testing** → **"+"** to create a group +2. Add build and testers +3. Fill in **Test Information**: + - **What to Test**: Brief description of features + - **Feedback Email**: Your email address +4. Click **Submit for Review** +5. Beta review typically takes 24-48 hours + +## Step 7: App Store Submission + +Once you're ready for public release: + +1. Go to **App Store** tab +2. Complete all required metadata (if not done) +3. Select your build +4. Fill in **App Review Information**: + - **Contact Information**: Your name, phone, email + - **Demo Account**: If app requires login + - **Notes**: Any special instructions for reviewers +5. Answer **Export Compliance** questions: + - Does your app use encryption? **Yes** (uses TLS/HTTPS) + - Is encryption registration required? **No** (standard encryption) +6. Click **Add for Review** +7. Review summary and click **Submit to App Review** + +## Step 8: After Submission + +- **App Review**: Typically 24-48 hours +- **Common Rejection Reasons**: + - Missing privacy policy + - Incomplete app information + - Crashes or bugs + - Misleading app description + +- **If Approved**: You can release immediately or schedule a release date +- **If Rejected**: Address issues and resubmit + +## Updating the App + +When you need to release an update: + +1. **Update version** in `pubspec.yaml`: + ```yaml + version: 0.5.0+6 # Increment version (0.5.0) and build number (+6) + ``` + +2. **Build new IPA**: + ```bash + export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" + ../flutter/bin/flutter clean + ../flutter/bin/flutter build ipa + ``` + +3. **Upload via Transporter** (same process as above) + +4. **Create new version** in App Store Connect: + - Click **"+"** next to versions + - Select version number + - Update "What's New" text + - Select new build + - Submit for review + +## macOS Build (Bonus) + +To build for macOS: + +```bash +export PATH="/opt/homebrew/lib/ruby/gems/4.0.0/bin:$PATH" +../flutter/bin/flutter build macos --release +cd build/macos/Build/Products/Release +zip -r meshcore_open-macos.zip meshcore_open.app +``` + +Distribution: +- Share the zip file directly +- Users unzip and drag to Applications +- First run: Right-click → Open (to bypass Gatekeeper) + +## Troubleshooting + +### Build Errors +- **CocoaPods not found**: Ensure PATH includes `/opt/homebrew/lib/ruby/gems/4.0.0/bin` +- **No signing certificate**: Configure Team in Xcode (Signing & Capabilities) +- **Bundle ID mismatch**: Check `ios/Runner.xcodeproj/project.pbxproj` + +### Upload Errors +- **No profiles found**: Create app in App Store Connect first +- **Bundle ID not registered**: Register in Apple Developer portal +- **Authentication failed**: Use Transporter app instead of CLI + +### TestFlight Issues +- **Build not appearing**: Wait 10-30 minutes for processing +- **Can't add testers**: Check you have available slots (100 internal, 10,000 external) +- **TestFlight crashes**: Check device logs in Xcode → Devices & Simulators + +## Important Files + +- **iOS IPA**: `build/ios/ipa/meshcore_open.ipa` +- **macOS App**: `build/macos/Build/Products/Release/meshcore_open.app` +- **Bundle ID Config**: `ios/Runner.xcodeproj/project.pbxproj` +- **Version Info**: `pubspec.yaml` + +## Useful Links + +- [App Store Connect](https://appstoreconnect.apple.com) +- [Apple Developer Portal](https://developer.apple.com/account) +- [TestFlight Documentation](https://developer.apple.com/testflight/) +- [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) +- [Flutter iOS Deployment](https://docs.flutter.dev/deployment/ios) + +## Support + +For issues with: +- **App Store Process**: [Apple Developer Support](https://developer.apple.com/contact/) +- **Flutter Build Issues**: [Flutter GitHub](https://github.com/flutter/flutter/issues) +- **Meshcore Open App**: [GitHub Issues](https://github.com/wel97459/meshcore-open/issues) diff --git a/lib/connector/meshcore_connector.dart b/lib/connector/meshcore_connector.dart index 3a36a92..d7d7dc9 100644 --- a/lib/connector/meshcore_connector.dart +++ b/lib/connector/meshcore_connector.dart @@ -95,6 +95,7 @@ class MeshCoreConnector extends ChangeNotifier { double? _selfLongitude; bool _isLoadingContacts = false; bool _isLoadingChannels = false; + bool _hasLoadedChannels = false; bool _batteryRequested = false; bool _awaitingSelfInfo = false; bool _preserveContactsOnRefresh = false; @@ -122,7 +123,7 @@ class MeshCoreConnector extends ChangeNotifier { List _previousChannelsCache = []; static const int _maxChannelSyncRetries = 3; static const int _channelSyncTimeoutMs = 2000; // 2 second timeout per channel - static const Duration _batteryPollInterval = Duration(seconds: 30); + static const Duration _batteryPollInterval = Duration(seconds: 120); // Services MessageRetryService? _retryService; @@ -927,6 +928,7 @@ class MeshCoreConnector extends ChangeNotifier { _pendingQueueSync = false; _isSyncingChannels = false; _channelSyncInFlight = false; + _hasLoadedChannels = false; _setState(MeshCoreConnectionState.disconnected); if (!manual) { @@ -1493,13 +1495,19 @@ class MeshCoreConnector extends ChangeNotifier { await sendCliCommand('set privacy ${enabled ? 'on' : 'off'}'); } - Future getChannels({int? maxChannels}) async { + Future getChannels({int? maxChannels, bool force = false}) async { if (!isConnected) return; if (_isSyncingChannels) { debugPrint('[ChannelSync] Already syncing channels, ignoring request'); return; } + // Skip fetching if already loaded and not forced + if (_hasLoadedChannels && !force) { + debugPrint('[ChannelSync] Channels already loaded, skipping fetch (use force=true to reload)'); + return; + } + _isLoadingChannels = true; _isSyncingChannels = true; _previousChannelsCache = List.from(_channels); @@ -1619,6 +1627,7 @@ class MeshCoreConnector extends ChangeNotifier { _totalChannelsToRequest = 0; if (completed) { + _hasLoadedChannels = true; _previousChannelsCache.clear(); } // Keep cache on failure/disconnection for future attempts @@ -1629,7 +1638,7 @@ class MeshCoreConnector extends ChangeNotifier { await sendFrame(buildSetChannelFrame(index, name, psk)); // Refresh channels after setting - await getChannels(); + await getChannels(force: true); } Future deleteChannel(int index) async { @@ -1644,7 +1653,7 @@ class MeshCoreConnector extends ChangeNotifier { // Clear in-memory messages for this channel _channelMessages.remove(index); // Refresh channels after deleting - await getChannels(); + await getChannels(force: true); } void _handleFrame(List data) { @@ -2105,6 +2114,15 @@ class MeshCoreConnector extends ChangeNotifier { } if (message != null) { + // Ignore messages from self (device hearing its own broadcast) + // BUT allow repeated messages (pathLength indicates it went through repeater) + if (_selfPublicKey != null && + message.senderKeyHex == pubKeyToHex(_selfPublicKey!) && + (message.pathLength == null || message.pathLength == 0)) { + debugPrint('Ignoring direct message from self'); + return; + } + final contact = _contacts.cast().firstWhere( (c) => c?.publicKeyHex == message!.senderKeyHex, orElse: () => null, @@ -3066,28 +3084,19 @@ class MeshCoreConnector extends ChangeNotifier { } bool _shouldDropSelfChannelMessage(String senderName, Uint8List pathBytes) { - final selfKey = _selfPublicKey; - if (selfKey == null) return false; - if (pathBytes.length < pathHashSize) return false; final trimmed = senderName.trim(); if (trimmed.isEmpty) return false; + final selfName = _selfName?.trim(); if (selfName == null || selfName.isEmpty) return false; + + // If sender name doesn't match, keep the message if (trimmed != selfName) return false; - final prefix = selfKey.sublist(0, pathHashSize); - for (int i = 0; i + pathHashSize <= pathBytes.length; i += pathHashSize) { - var match = true; - for (int j = 0; j < pathHashSize; j++) { - if (pathBytes[i + j] != prefix[j]) { - match = false; - break; - } - } - if (match) { - return true; - } - } - return false; + + // Name matches - this is from self + // Drop only if pathBytes is empty (direct broadcast) + // Keep if pathBytes has data (repeated through another node) + return pathBytes.isEmpty; } Uint8List _selectPreferredPathBytes(Uint8List existing, Uint8List incoming) { diff --git a/lib/main.dart b/lib/main.dart index 6dac19b..cd0887f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -128,6 +128,9 @@ class MeshCoreApp extends StatelessWidget { theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true, + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), ), darkTheme: ThemeData( colorScheme: ColorScheme.fromSeed( @@ -135,6 +138,9 @@ class MeshCoreApp extends StatelessWidget { brightness: Brightness.dark, ), useMaterial3: true, + snackBarTheme: const SnackBarThemeData( + behavior: SnackBarBehavior.floating, + ), ), themeMode: _themeModeFromSetting(settingsService.settings.themeMode), home: const ScannerScreen(), diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index efd7340..e54f3f1 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -164,7 +164,7 @@ class _ChannelsScreenState extends State ), body: RefreshIndicator( onRefresh: () async { - await context.read().getChannels(); + await context.read().getChannels(force: true); }, child: () { if (connector.isLoadingChannels) { diff --git a/lib/screens/map_screen.dart b/lib/screens/map_screen.dart index edef811..734f2b2 100644 --- a/lib/screens/map_screen.dart +++ b/lib/screens/map_screen.dart @@ -225,13 +225,15 @@ class _MapScreenState extends State { } // Re center map after removed markers have loaded - if (!_hasInitializedMap && _removedMarkersLoaded && hasMapContent) { + if (!_hasInitializedMap && _removedMarkersLoaded) { _hasInitializedMap = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _mapController.move(center, initialZoom); - } - }); + if (hasMapContent) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _mapController.move(center, initialZoom); + } + }); + } } final allowBack = !connector.isConnected; @@ -275,9 +277,7 @@ class _MapScreenState extends State { ), ], ), - body: !hasMapContent - ? _buildEmptyState() - : Stack( + body: Stack( children: [ FlutterMap( mapController: _mapController, @@ -376,27 +376,6 @@ class _MapScreenState extends State { ); } - Widget _buildEmptyState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.location_off, size: 64, color: Colors.grey[400]), - const SizedBox(height: 16), - Text( - context.l10n.map_noNodesWithLocation, - style: TextStyle(fontSize: 18, color: Colors.grey[600]), - ), - const SizedBox(height: 8), - Text( - context.l10n.map_nodesNeedGps, - textAlign: TextAlign.center, - style: TextStyle(fontSize: 14, color: Colors.grey[500]), - ), - ], - ), - ); - } List _buildMarkers(List contacts, settings) { final markers = []; diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c6a85d7..04740d8 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -780,10 +780,15 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { return; } - if (txPower == null || txPower < 0 || txPower > 22) { + final maxTxPower = widget.connector.maxTxPower ?? 22; + if (txPower == null || txPower < 0 || txPower > maxTxPower) { ScaffoldMessenger.of( context, - ).showSnackBar(SnackBar(content: Text(l10n.settings_txPowerInvalid))); + ).showSnackBar( + SnackBar( + content: Text('${l10n.settings_txPowerInvalid} (0-$maxTxPower dBm)'), + ), + ); return; } @@ -932,7 +937,9 @@ class _RadioSettingsDialogState extends State<_RadioSettingsDialog> { decoration: InputDecoration( labelText: l10n.settings_txPower, border: const OutlineInputBorder(), - helperText: l10n.settings_txPowerHelper, + helperText: widget.connector.maxTxPower != null + ? '${l10n.settings_txPowerHelper} (max: ${widget.connector.maxTxPower} dBm)' + : l10n.settings_txPowerHelper, ), keyboardType: TextInputType.number, ), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index a87b4cf..65fed26 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -9,9 +9,6 @@ PODS: - FlutterMacOS - package_info_plus (0.0.1): - FlutterMacOS - - path_provider_foundation (0.0.1): - - Flutter - - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -29,7 +26,6 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) @@ -46,8 +42,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos package_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos - path_provider_foundation: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin sqflite_darwin: @@ -63,7 +57,6 @@ SPEC CHECKSUMS: FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 mobile_scanner: 0e365ed56cad24f28c0fd858ca04edefb40dfac3 package_info_plus: f0052d280d17aa382b932f399edf32507174e870 - path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd