DEV Community

Cover image for The Developer's Guide to Actually Private Apps: No Cloud, No Analytics, No Tracking
Karol Burdziński
Karol Burdziński

Posted on

The Developer's Guide to Actually Private Apps: No Cloud, No Analytics, No Tracking

The Developer's Guide to Actually Private Apps: No Cloud, No Analytics, No Tracking

How to build truly private mobile apps using Flutter — with practical patterns, code examples, and architectural decisions that work on both iOS and Android


TL;DR: This guide walks through building truly private mobile apps using offline-first architecture with Flutter/Dart, discussing encryption, storage, permissions, and the tradeoffs between privacy and convenience.


The Privacy Problem in Modern App Development

Let's be honest: most of us are building apps that leak user data.

Not maliciously — usually through third-party packages, analytics tools, crash reporters, and cloud services we integrate without thinking twice.

Here's a typical "secure" app stack:

User Data
  ↓
App Logic
  ↓
├─ Firebase Analytics
├─ Crashlytics
├─ Cloud Storage SDK
├─ Authentication Service
└─ Ad Network SDK
  ↓
[Multiple Third-Party Servers]
Enter fullscreen mode Exit fullscreen mode

Each integration point is a potential data leak. Each package has its own privacy policy. Each server is a breach waiting to happen.

What if we just... didn't do any of that?

The Offline-First Philosophy

Offline-first doesn't mean "works offline as a fallback." It means:

The app functions completely without network access, and network features (if any) are optional, explicit, and user-controlled.

For truly private apps, this principle becomes even stricter:

No network access, period. Everything stays on the device.

Why This Matters

If your app doesn't use the network:

  • No data can leak to third parties
  • No servers to breach
  • No privacy policy complications
  • No GDPR/CCPA compliance issues (for the app itself)
  • Users have complete control

The tradeoff? You lose:

  • Cloud sync
  • Cross-device access
  • Analytics insights
  • Remote feature flags
  • Push notifications
  • Crash reporting to your servers

For many apps, especially those handling sensitive data, this is a worthwhile tradeoff.

Building Blocks: Flutter/Dart Example

Let me walk through building a private vault app using Flutter. These patterns work on both iOS and Android with a single codebase.

1. Permissions: Request Only What's Necessary

DON'T:

<!-- AndroidManifest.xml with unnecessary permissions -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
Enter fullscreen mode Exit fullscreen mode
<!-- iOS Info.plist with unnecessary permissions -->
<key>NSLocationWhenInUseUsageDescription</key>
<string>To provide better service</string>
<key>NSContactsUsageDescription</key>
<string>To help you share</string>
Enter fullscreen mode Exit fullscreen mode

DO:

<!-- AndroidManifest.xml - minimal permissions -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<!-- Note: Explicitly NO internet permission -->
Enter fullscreen mode Exit fullscreen mode
<!-- iOS Info.plist - minimal permissions -->
<key>NSPhotoLibraryUsageDescription</key>
<string>To import photos into your encrypted vault</string>
<key>NSCameraUsageDescription</key>
<string>To capture documents for your vault</string>
<!-- That's it. No location, no contacts, no tracking. -->
Enter fullscreen mode Exit fullscreen mode

Flutter pubspec.yaml - avoid network-dependent packages:

dependencies:
  flutter:
    sdk: flutter

  # Local storage & database
  sqflite: ^2.4.2
  path_provider: ^2.1.5
  shared_preferences: ^2.5.3

  # Security & encryption
  flutter_secure_storage: ^9.2.4
  biometric_storage: ^5.0.1
  fastcrypt: ^1.0.0  # Fast ChaCha encryption
  crypto: ^3.0.6

  # Authentication
  local_auth: ^2.3.0

  # Image & file handling
  camera: ^0.11.1
  image_picker: ^1.1.2
  file_picker: ^10.3.7
  photo_view: ^0.15.0
  flutter_pdfview: ^1.4.3

  # State management
  flutter_bloc: ^9.1.1

  # Navigation
  go_router: ^16.0.0

  # Utilities
  uuid: ^4.5.1
  permission_handler: ^12.0.1
  device_info_plus: ^11.5.0

  # Monetization (RevenueCat)
  purchases_flutter: ^9.2.1
  purchases_ui_flutter: ^9.4.0

  # DON'T include these network-dependent packages:
  # firebase_core
  # firebase_analytics
  # dio (HTTP client)
  # http
  # cloud_firestore
Enter fullscreen mode Exit fullscreen mode

Key Principle: Only request permissions you absolutely need, and explain exactly why.

2. Storage: Keep Everything Local

DON'T:

// Using a cloud-synced database
import 'package:cloud_firestore/cloud_firestore.dart';

final db = FirebaseFirestore.instance;
await db.collection('vaults').doc(userId).set({
  'encryptedData': encryptedData,
  'timestamp': FieldValue.serverTimestamp(),
});
Enter fullscreen mode Exit fullscreen mode

DO:

// Using local-only SQLite database
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class VaultDatabase {
  static Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDatabase();
    return _database!;
  }

  Future<Database> _initDatabase() async {
    final dbPath = await getDatabasesPath();
    final path = join(dbPath, 'vault.db');

    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE documents(
            id TEXT PRIMARY KEY,
            encrypted_data BLOB NOT NULL,
            encrypted_thumbnail BLOB,
            created_at INTEGER NOT NULL,
            file_type TEXT NOT NULL
          )
        ''');
      },
    );
  }

  Future<void> saveDocument({
    required String id,
    required List<int> encryptedData,
    List<int>? encryptedThumbnail,
    required String fileType,
  }) async {
    final db = await database;
    await db.insert(
      'documents',
      {
        'id': id,
        'encrypted_data': encryptedData,
        'encrypted_thumbnail': encryptedThumbnail,
        'created_at': DateTime.now().millisecondsSinceEpoch,
        'file_type': fileType,
      },
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<Map<String, dynamic>?> getDocument(String id) async {
    final db = await database;
    final results = await db.query(
      'documents',
      where: 'id = ?',
      whereArgs: [id],
    );
    return results.isNotEmpty ? results.first : null;
  }

  Future<List<Map<String, dynamic>>> getAllDocuments() async {
    final db = await database;
    return await db.query('documents', orderBy: 'created_at DESC');
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternative: Secure File System Storage

import 'dart:io';
import 'package:path_provider/path_provider.dart';

class SecureFileStorage {
  Future<Directory> get _vaultDirectory async {
    final appDir = await getApplicationDocumentsDirectory();
    final vaultDir = Directory('${appDir.path}/encrypted_vault');

    if (!await vaultDir.exists()) {
      await vaultDir.create(recursive: true);
    }

    return vaultDir;
  }

  Future<void> saveFile(String filename, List<int> encryptedData) async {
    final dir = await _vaultDirectory;
    final file = File('${dir.path}/$filename');
    await file.writeAsBytes(encryptedData);
  }

  Future<List<int>?> readFile(String filename) async {
    try {
      final dir = await _vaultDirectory;
      final file = File('${dir.path}/$filename');
      if (await file.exists()) {
        return await file.readAsBytes();
      }
    } catch (e) {
      print('Error reading file: $e');
    }
    return null;
  }

  Future<void> deleteFile(String filename) async {
    final dir = await _vaultDirectory;
    final file = File('${dir.path}/$filename');
    if (await file.exists()) {
      await file.delete();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Principle: Use platform-native, local-only storage. SQLite for structured data, file system for blobs.

3. Encryption: Do It Right

DON'T:

// Weak encryption or key management
const key = "MySecretKey123"; // Hard-coded key
final encrypted = encryptData(data, key);
Enter fullscreen mode Exit fullscreen mode

DO:

import 'package:fastcrypt/fastcrypt.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:crypto/crypto.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'dart:math';
import 'dart:isolate';

class EncryptionManager {
  final _secureStorage = const FlutterSecureStorage();
  static const _keyIdentifier = 'vault_encryption_key';
  final _random = Random.secure();

  // Custom header to identify Proof Pocket encrypted files
  static const _header = [0x50, 0x50, 0x46, 0x32]; // 'PPF2'

  // Generate secure random bytes
  Uint8List _generateSecureRandom(int length) {
    final bytes = Uint8List(length);
    for (int i = 0; i < length; i++) {
      bytes[i] = _random.nextInt(256);
    }
    return bytes;
  }

  // Generate and securely store encryption key
  Future<void> generateEncryptionKey() async {
    // Generate a secure 256-bit key for ChaCha20-Poly1305
    final key = _generateSecureRandom(32); // 32 bytes = 256 bits
    await _secureStorage.write(
      key: _keyIdentifier,
      value: base64.encode(key),
      // On Android: uses EncryptedSharedPreferences
      // On iOS: uses Keychain
      aOptions: const AndroidOptions(
        encryptedSharedPreferences: true,
      ),
      iOptions: const IOSOptions(
        accessibility: KeychainAccessibility.first_unlock_this_device,
      ),
    );
  }

  // Retrieve the encryption key
  Future<Uint8List?> _getKey() async {
    final keyString = await _secureStorage.read(key: _keyIdentifier);
    if (keyString == null) return null;
    return Uint8List.fromList(base64.decode(keyString));
  }

  // Encrypt data using ChaCha20-Poly1305 (in background isolate)
  Future<Uint8List> encryptData(Uint8List data) async {
    final key = await _getKey();
    if (key == null) {
      throw Exception('Encryption key not found. Call generateEncryptionKey() first.');
    }

    // Run encryption in isolate to avoid blocking UI
    return await compute(_encryptCompute, {
      'bytes': TransferableTypedData.fromList([data]),
      'key': key.toList(),
    });
  }

  // Encryption computation (runs in isolate)
  static Uint8List _encryptCompute(Map<String, Object> args) {
    final header = _header;
    final bytes = (args['bytes'] as TransferableTypedData)
        .materialize()
        .asUint8List();
    final key = Uint8List.fromList((args['key'] as List).cast<int>());

    // Use FastCrypt for ChaCha20-Poly1305 encryption
    final fc = FastCrypt();
    final enc = fc.encryptBytes(bytes, key: key);

    // Format: [header][nonce][tag][ciphertext]
    return Uint8List.fromList([
      ...header,
      ...enc.nonce,
      ...enc.tag,
      ...enc.ciphertext,
    ]);
  }

  // Decrypt data using ChaCha20-Poly1305 (in background isolate)
  Future<Uint8List> decryptData(Uint8List encryptedData) async {
    final key = await _getKey();
    if (key == null) {
      throw Exception('Encryption key not found');
    }

    // Run decryption in isolate to avoid blocking UI
    return await compute(_decryptCompute, {
      'bytes': TransferableTypedData.fromList([encryptedData]),
      'key': key.toList(),
    });
  }

  // Decryption computation (runs in isolate)
  static Uint8List _decryptCompute(Map<String, Object> args) {
    final header = _header;
    final data = (args['bytes'] as TransferableTypedData)
        .materialize()
        .asUint8List();
    final key = Uint8List.fromList((args['key'] as List).cast<int>());

    // Parse encrypted format: [header][nonce][tag][ciphertext]
    const nonceLen = 12;
    const tagLen = 16;

    if (data.length < header.length + nonceLen + tagLen) {
      throw Exception('Encrypted data not valid or corrupted.');
    }

    // Verify header
    for (var i = 0; i < header.length; i++) {
      if (data[i] != header[i]) {
        throw Exception('Unknown encrypted format.');
      }
    }

    // Extract components
    final offset = header.length;
    final nonce = data.sublist(offset, offset + nonceLen);
    final tag = data.sublist(offset + nonceLen, offset + nonceLen + tagLen);
    final ciphertext = data.sublist(offset + nonceLen + tagLen);

    // Decrypt using FastCrypt
    final fc = FastCrypt();
    return Uint8List.fromList(
      fc.decryptBytes(
        ciphertext: ciphertext,
        nonce: nonce,
        tag: tag,
        key: key,
      ),
    );
  }

  // Hash data (useful for verifying integrity)
  Uint8List hashData(Uint8List data) {
    final digest = sha256.convert(data);
    return Uint8List.fromList(digest.bytes);
  }

  // Check if encryption key exists
  Future<bool> hasEncryptionKey() async {
    final key = await _secureStorage.read(key: _keyIdentifier);
    return key != null;
  }

  // Delete encryption key (for app reset)
  Future<void> deleteEncryptionKey() async {
    await _secureStorage.delete(key: _keyIdentifier);
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternative: Using biometric_storage for extra security

import 'package:biometric_storage/biometric_storage.dart';

class BiometricEncryptionManager {
  StorageFile? _storageFile;

  // Initialize biometric storage
  Future<void> initialize() async {
    final canAuthenticate = await BiometricStorage().canAuthenticate();

    if (canAuthenticate == CanAuthenticateResponse.success) {
      _storageFile = await BiometricStorage().getStorage(
        'vault_key_storage',
        options: StorageFileInitOptions(
          authenticationRequired: true,
          authenticationValidityDurationSeconds: 30,
        ),
      );
    } else {
      throw Exception('Biometric authentication not available');
    }
  }

  // Store encryption key with biometric protection
  Future<void> storeKeyWithBiometrics(String key) async {
    await _storageFile?.write(key);
  }

  // Retrieve key (requires biometric authentication)
  Future<String?> retrieveKeyWithBiometrics() async {
    return await _storageFile?.read();
  }
}
Enter fullscreen mode Exit fullscreen mode

Required packages in pubspec.yaml:

dependencies:
  fastcrypt: ^1.0.0
  crypto: ^3.0.6
  flutter_secure_storage: ^9.2.4
  biometric_storage: ^5.0.1  # Optional, for biometric-protected keys
Enter fullscreen mode Exit fullscreen mode

Key Principles:

  • Use ChaCha20-Poly1305 (via Fastcrypt) - modern, fast authenticated encryption
  • Fastcrypt's API: encryptBytes() returns an object with nonce, tag, and ciphertext
  • Store encrypted format with custom header for file type identification
  • Run encryption/decryption in background isolates using compute() to avoid blocking UI
  • ChaCha20-Poly1305 provides both encryption and authentication (no separate MAC needed)
  • Store keys in platform secure storage:
    • iOS: Keychain
    • Android: EncryptedSharedPreferences (uses Android Keystore)
  • Never hard-code keys
  • Custom file format: [Header: 4 bytes][Nonce: 12 bytes][Tag: 16 bytes][Ciphertext: variable]
  • Consider biometric_storage for extra protection of encryption keys

Why ChaCha20-Poly1305?

  • Faster than AES on mobile devices without hardware AES acceleration
  • Authenticated encryption (prevents tampering)
  • Widely used and well-vetted (used in TLS, Signal, WhatsApp)
  • No timing attack vulnerabilities

Why use isolates for encryption?

  • Prevents UI blocking for large files
  • Better performance on multi-core devices
  • compute() handles isolate communication automatically

4. Authentication: Use Device Security

DON'T:

// Custom authentication that might leak credentials
class AuthService {
  Future<void> login(String username, String password) async {
    final response = await api.login(username, password);
    // Credentials sent to server
  }
}
Enter fullscreen mode Exit fullscreen mode

DO:

import 'package:local_auth/local_auth.dart';
import 'package:permission_handler/permission_handler.dart';

class BiometricAuth {
  final LocalAuthentication _auth = LocalAuthentication();

  // Check if device supports biometrics
  Future<bool> canUseBiometrics() async {
    try {
      return await _auth.canCheckBiometrics;
    } catch (e) {
      return false;
    }
  }

  // Check if device has biometrics enrolled
  Future<bool> isDeviceSupported() async {
    try {
      return await _auth.isDeviceSupported();
    } catch (e) {
      return false;
    }
  }

  // Get available biometric types
  Future<List<BiometricType>> getAvailableBiometrics() async {
    try {
      return await _auth.getAvailableBiometrics();
    } catch (e) {
      return [];
    }
  }

  // Authenticate user with biometrics or device PIN/password
  Future<bool> authenticateUser() async {
    try {
      final canCheckBiometrics = await canUseBiometrics();
      final isSupported = await isDeviceSupported();

      if (!canCheckBiometrics || !isSupported) {
        // Fall back to device credentials (PIN/password/pattern)
        return await _authenticateWithDeviceCredentials();
      }

      // Try biometric authentication
      final authenticated = await _auth.authenticate(
        localizedReason: 'Unlock your encrypted vault',
        options: const AuthenticationOptions(
          stickyAuth: true,
          biometricOnly: false, // Allow PIN fallback
          useErrorDialogs: true,
          sensitiveTransaction: true,
        ),
      );

      return authenticated;
    } catch (e) {
      print('Authentication error: $e');
      return false;
    }
  }

  // Authenticate with device credentials only (no biometrics)
  Future<bool> _authenticateWithDeviceCredentials() async {
    try {
      return await _auth.authenticate(
        localizedReason: 'Unlock your encrypted vault',
        options: const AuthenticationOptions(
          stickyAuth: true,
          biometricOnly: false,
          useErrorDialogs: true,
        ),
      );
    } catch (e) {
      print('Device credentials error: $e');
      return false;
    }
  }

  // Stop authentication
  Future<void> stopAuthentication() async {
    await _auth.stopAuthentication();
  }
}

// Usage example with permission handling
class VaultAccessManager {
  final BiometricAuth _biometricAuth = BiometricAuth();

  // Request biometric permissions (Android 11+)
  Future<bool> requestBiometricPermission() async {
    if (Platform.isAndroid) {
      final status = await Permission.biometric.request();
      return status.isGranted;
    }
    return true; // iOS doesn't need explicit permission request
  }

  Future<bool> unlockVault() async {
    // Request permission first (Android)
    final hasPermission = await requestBiometricPermission();
    if (!hasPermission) {
      return false;
    }

    // Authenticate user
    final authenticated = await _biometricAuth.authenticateUser();

    if (authenticated) {
      // User is authenticated, allow vault access
      return true;
    } else {
      // Authentication failed
      return false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Android-specific configuration (AndroidManifest.xml):

<manifest>
    <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
    <!-- For older Android versions -->
    <uses-permission android:name="android.permission.USE_FINGERPRINT"/>
</manifest>
Enter fullscreen mode Exit fullscreen mode

iOS-specific configuration (Info.plist):

<key>NSFaceIDUsageDescription</key>
<string>Unlock your encrypted vault using Face ID</string>
Enter fullscreen mode Exit fullscreen mode

Required packages:

dependencies:
  local_auth: ^2.3.0
  permission_handler: ^12.0.1
Enter fullscreen mode Exit fullscreen mode

Key Principle: Leverage device-level security instead of custom authentication systems. No passwords to store, no credentials to leak.

5. No Analytics: Build Blind

DON'T:

// Tracking user behavior
import 'package:firebase_analytics/firebase_analytics.dart';

final analytics = FirebaseAnalytics.instance;
await analytics.logEvent(
  name: 'document_added',
  parameters: {
    'document_type': 'passport',
    'file_size': fileSize,
  },
);
Enter fullscreen mode Exit fullscreen mode

DO:

// Nothing. Just don't track.

// If you MUST understand usage (for debugging), use local-only logging
class PrivacyPreservingLogger {
  void logEvent(String event, [Map<String, dynamic>? params]) {
    // In debug mode only
    assert(() {
      print('Event: $event');
      if (params != null) print('Params: $params');
      return true;
    }());
    // In production: nothing. No tracking, period.
  }
}
Enter fullscreen mode Exit fullscreen mode

Alternative: Privacy-Preserving Telemetry (If You Must)

If you absolutely need some usage data, consider:

import 'package:shared_preferences/shared_preferences.dart';

// Local-only, aggregated, anonymous stats
class LocalAnalytics {
  static const _prefix = 'analytics_';

  Future<void> incrementCounter(String event) async {
    final prefs = await SharedPreferences.getInstance();
    final key = '$_prefix$event';
    final current = prefs.getInt(key) ?? 0;
    await prefs.setInt(key, current + 1);
  }

  // Stats never leave the device
  // User can view them in Settings if they want
  Future<Map<String, int>> getStats() async {
    final prefs = await SharedPreferences.getInstance();
    final stats = <String, int>{};

    for (final key in prefs.getKeys()) {
      if (key.startsWith(_prefix)) {
        final eventName = key.substring(_prefix.length);
        stats[eventName] = prefs.getInt(key) ?? 0;
      }
    }

    return stats;
  }

  // Allow users to clear their local stats
  Future<void> clearStats() async {
    final prefs = await SharedPreferences.getInstance();
    for (final key in prefs.getKeys()) {
      if (key.startsWith(_prefix)) {
        await prefs.remove(key);
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Principle: If you can't see the data, you can't leak it.

6. Network Isolation: Enforce It

AndroidManifest.xml Configuration:

<!-- Explicitly do NOT request internet permission -->
<!-- If not declared, app cannot access network -->
<!-- Only include permissions you actually need: -->
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
Enter fullscreen mode Exit fullscreen mode

iOS Info.plist Configuration:

<!-- Disable ATS to prevent any network access -->
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
</dict>
Enter fullscreen mode Exit fullscreen mode

Code-Level Enforcement:

import 'package:connectivity_plus/connectivity_plus.dart';

// Create a network monitor to ensure offline operation
class NetworkGuard {
  static final NetworkGuard _instance = NetworkGuard._internal();
  factory NetworkGuard() => _instance;
  NetworkGuard._internal();

  final _connectivity = Connectivity();

  void initialize() {
    _connectivity.onConnectivityChanged.listen((result) {
      if (result != ConnectivityResult.none) {
        // Network is available, but we don't use it
        _logWarning('Network detected but not used (offline-first)');
      }
    });
  }

  void _logWarning(String message) {
    assert(() {
      print('⚠️ NetworkGuard: $message');
      return true;
    }());
  }
}

// Initialize in main.dart
void main() {
  NetworkGuard().initialize();
  runApp(MyApp());
}
Enter fullscreen mode Exit fullscreen mode

Verify No Network Packages in pubspec.yaml:

dependencies:
  # ✅ GOOD - Local only
  sqflite: ^2.3.0
  path_provider: ^2.1.1
  shared_preferences: ^2.2.2

  # ❌ BAD - Remove these
  # http: 
  # dio:
  # firebase_core:
  # cloud_firestore:
Enter fullscreen mode Exit fullscreen mode

Key Principle: Make network access impossible by not requesting permissions, not just unused.

Advanced Topics

Handling Photos & PDFs

import 'dart:io';
import 'dart:typed_data';
import 'package:camera/camera.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';

class DocumentProcessor {
  final EncryptionManager _encryption = EncryptionManager();
  final ImagePicker _picker = ImagePicker();

  // Capture photo using camera
  Future<Uint8List?> capturePhotoWithCamera() async {
    try {
      final cameras = await availableCameras();
      if (cameras.isEmpty) {
        throw Exception('No cameras available');
      }

      // You would typically navigate to a camera screen here
      // and return the captured image bytes
      // This is a simplified example

      final XFile? photo = await _picker.pickImage(
        source: ImageSource.camera,
        maxWidth: 1920,
        maxHeight: 1920,
        imageQuality: 85,
      );

      if (photo == null) return null;

      final imageBytes = await photo.readAsBytes();

      // Encrypt immediately
      return await _encryption.encryptData(imageBytes);
    } catch (e) {
      print('Error capturing photo: $e');
      return null;
    }
  }

  // Import photo from gallery
  Future<Uint8List?> importPhotoFromGallery() async {
    try {
      final XFile? pickedFile = await _picker.pickImage(
        source: ImageSource.gallery,
        maxWidth: 1920,
        maxHeight: 1920,
        imageQuality: 85,
      );

      if (pickedFile == null) return null;

      final imageBytes = await pickedFile.readAsBytes();

      // Encrypt immediately
      return await _encryption.encryptData(imageBytes);
    } catch (e) {
      print('Error importing photo: $e');
      return null;
    }
  }

  // Import PDF file using file_picker
  Future<Uint8List?> importPDF() async {
    try {
      FilePickerResult? result = await FilePicker.platform.pickFiles(
        type: FileType.custom,
        allowedExtensions: ['pdf'],
        allowMultiple: false,
      );

      if (result == null || result.files.isEmpty) return null;

      final file = File(result.files.single.path!);
      final pdfBytes = await file.readAsBytes();

      // Validate PDF size (prevent DoS)
      if (pdfBytes.length > 10 * 1024 * 1024) { // 10MB limit
        throw Exception('PDF file too large (max 10MB)');
      }

      // Encrypt immediately
      return await _encryption.encryptData(pdfBytes);
    } catch (e) {
      print('Error importing PDF: $e');
      return null;
    }
  }

  // Generate thumbnail from encrypted image
  Future<Uint8List?> generateThumbnail(Uint8List encryptedImageData) async {
    try {
      // Decrypt temporarily to generate thumbnail
      final decryptedBytes = await _encryption.decryptData(encryptedImageData);

      // For production, use image processing library
      // This is simplified - you'd want to resize the image
      final thumbnailBytes = decryptedBytes; // Placeholder

      // In production, resize to 200x200 using image package
      // final image = img.decodeImage(decryptedBytes);
      // final thumbnail = img.copyResize(image, width: 200, height: 200);
      // final thumbnailBytes = img.encodeJpg(thumbnail, quality: 70);

      // Re-encrypt thumbnail
      return await _encryption.encryptData(thumbnailBytes);
    } catch (e) {
      print('Error generating thumbnail: $e');
      return null;
    }
  }

  // Display encrypted PDF (decrypt in memory, don't save)
  Future<void> displayEncryptedPDF(
    Uint8List encryptedPdfData,
    String tempFilePath,
  ) async {
    try {
      // Decrypt PDF
      final decryptedPdf = await _encryption.decryptData(encryptedPdfData);

      // Write to temporary file for PDFView widget
      final tempFile = File(tempFilePath);
      await tempFile.writeAsBytes(decryptedPdf);

      // Use flutter_pdfview to display:
      // PDFView(
      //   filePath: tempFilePath,
      //   enableSwipe: true,
      //   onError: (error) => print(error),
      //   onPageError: (page, error) => print('$page: ${error.toString()}'),
      // )

      // IMPORTANT: Delete temp file after viewing
      await tempFile.delete();
    } catch (e) {
      print('Error displaying PDF: $e');
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Required packages:

dependencies:
  camera: ^0.11.1
  image_picker: ^1.1.2
  file_picker: ^10.3.7
  flutter_pdfview: ^1.4.3
  # Optional: for advanced image processing
  # image: ^4.1.3
Enter fullscreen mode Exit fullscreen mode

Key Principle: Encrypt everything immediately after import. Only decrypt temporarily when needed for display, never save decrypted data.

Data Export (User Control)

import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; // Optional: for sharing files

class DataExporter {
  final VaultDatabase _database = VaultDatabase();

  // Export all encrypted data to a directory
  Future<Directory?> exportAllData() async {
    try {
      final appDir = await getApplicationDocumentsDirectory();
      final timestamp = DateTime.now().millisecondsSinceEpoch;
      final exportDir = Directory('${appDir.path}/vault_export_$timestamp');
      await exportDir.create(recursive: true);

      // Get all documents from database
      final documents = await _database.getAllDocuments();

      // Copy all encrypted files to export directory
      for (var i = 0; i < documents.length; i++) {
        final doc = documents[i];
        final encryptedData = doc['encrypted_data'] as List<int>;
        final file = File('${exportDir.path}/document_$i.enc');
        await file.writeAsBytes(encryptedData);
      }

      // Create metadata file
      final metadata = {
        'export_date': DateTime.now().toIso8601String(),
        'document_count': documents.length,
        'version': '1.0',
        'app': 'Proof Pocket',
      };
      final metadataFile = File('${exportDir.path}/metadata.json');
      await metadataFile.writeAsString(jsonEncode(metadata));

      return exportDir;
    } catch (e) {
      print('Error exporting data: $e');
      return null;
    }
  }

  // Import from exported directory
  Future<bool> importData(Directory exportDir) async {
    try {
      // Read metadata
      final metadataFile = File('${exportDir.path}/metadata.json');
      if (!await metadataFile.exists()) {
        throw Exception('Invalid backup: missing metadata');
      }

      final metadataContent = await metadataFile.readAsString();
      final metadata = jsonDecode(metadataContent) as Map<String, dynamic>;

      print('Importing ${metadata['document_count']} documents...');

      // Import encrypted documents
      final files = exportDir.listSync()
          .where((f) => f.path.endsWith('.enc'))
          .cast<File>();

      for (final file in files) {
        final encryptedData = await file.readAsBytes();
        await _database.saveDocument(
          id: uuid.v4(), // Generate new ID
          encryptedData: encryptedData,
          fileType: 'imported',
        );
      }

      return true;
    } catch (e) {
      print('Error importing data: $e');
      return false;
    }
  }

  // Create a single backup file (all docs in one file)
  Future<File?> createBackupFile() async {
    try {
      final tempDir = await getTemporaryDirectory();
      final timestamp = DateTime.now().millisecondsSinceEpoch;
      final backupFile = File('${tempDir.path}/proof_pocket_backup_$timestamp.ppb');

      final documents = await _database.getAllDocuments();

      // Create backup structure
      final backup = {
        'version': '1.0',
        'export_date': DateTime.now().toIso8601String(),
        'documents': documents.map((doc) => {
          'id': doc['id'],
          'encrypted_data': base64.encode(doc['encrypted_data'] as List<int>),
          'file_type': doc['file_type'],
          'created_at': doc['created_at'],
        }).toList(),
      };

      // Write as JSON
      await backupFile.writeAsString(jsonEncode(backup));

      return backupFile;
    } catch (e) {
      print('Error creating backup: $e');
      return null;
    }
  }

  // Restore from backup file
  Future<bool> restoreFromBackup(File backupFile) async {
    try {
      final content = await backupFile.readAsString();
      final backup = jsonDecode(content) as Map<String, dynamic>;

      final documents = backup['documents'] as List<dynamic>;

      for (final doc in documents) {
        final encryptedData = base64.decode(doc['encrypted_data'] as String);
        await _database.saveDocument(
          id: doc['id'] as String,
          encryptedData: encryptedData,
          fileType: doc['file_type'] as String,
        );
      }

      return true;
    } catch (e) {
      print('Error restoring backup: $e');
      return false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Required packages:

dependencies:
  path_provider: ^2.1.5
  uuid: ^4.5.1
  # Optional for sharing:
  # share_plus: ^7.2.1
Enter fullscreen mode Exit fullscreen mode

Key Principle: Give users full control over their data without involving servers. All export/import happens locally. Users can manually transfer backup files between devices.

Testing Privacy Compliance

1. Network Traffic Analysis

# Use Charles Proxy or mitmproxy to monitor network
# Your app should show ZERO network requests

# Android: Use adb logcat
adb logcat | grep -i "http\|network\|socket"

# Should return nothing related to your app

# iOS Simulator
xcrun simctl spawn booted log stream --predicate 'process == "Runner"' | grep -i "http"

# Should return nothing
Enter fullscreen mode Exit fullscreen mode

2. Permission Auditing

// Create a test to verify no unexpected permissions
import 'package:flutter_test/flutter_test.dart';
import 'dart:io';

void main() {
  test('Android manifest has only required permissions', () async {
    final manifestFile = File('android/app/src/main/AndroidManifest.xml');
    final manifest = await manifestFile.readAsString();

    // Define allowed permissions
    final allowedPermissions = [
      'android.permission.CAMERA',
      'android.permission.READ_EXTERNAL_STORAGE',
      'android.permission.WRITE_EXTERNAL_STORAGE',
      'android.permission.USE_BIOMETRIC',
    ];

    // Check for INTERNET permission (should NOT exist)
    expect(manifest.contains('android.permission.INTERNET'), false,
        reason: 'App should not request INTERNET permission');

    // Verify no unexpected permissions
    final permissionRegex = RegExp(r'<uses-permission android:name="([^"]+)"');
    final matches = permissionRegex.allMatches(manifest);

    for (final match in matches) {
      final permission = match.group(1)!;
      final permissionName = permission.split('.').last;
      expect(allowedPermissions.contains(permission), true,
          reason: 'Unexpected permission: $permission');
    }
  });

  test('pubspec.yaml has no network packages', () async {
    final pubspecFile = File('pubspec.yaml');
    final pubspec = await pubspecFile.readAsString();

    // Packages that should NOT be present
    final bannedPackages = [
      'http:',
      'dio:',
      'firebase_core:',
      'firebase_analytics:',
      'cloud_firestore:',
    ];

    for (final package in bannedPackages) {
      expect(pubspec.contains(package), false,
          reason: 'Network package detected: $package');
    }
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Storage Verification

import 'package:flutter_test/flutter_test.dart';

void main() {
  test('Verify encryption is applied before storage', () async {
    final encryption = EncryptionManager();
    final database = VaultDatabase();

    // Generate key
    await encryption.generateEncryptionKey();

    // Test data
    final originalData = Uint8List.fromList([1, 2, 3, 4, 5]);

    // Encrypt
    final encryptedData = await encryption.encryptData(originalData);

    // Verify encrypted data is different from original
    expect(encryptedData, isNot(equals(originalData)));
    expect(encryptedData.length, greaterThan(originalData.length));

    // Save to database
    await database.saveDocument(
      id: 'test-doc',
      encryptedData: encryptedData,
      fileType: 'test',
    );

    // Retrieve and verify
    final retrieved = await database.getDocument('test-doc');
    expect(retrieved, isNotNull);
    expect(retrieved!['encrypted_data'], equals(encryptedData));

    // Decrypt and verify
    final decrypted = await encryption.decryptData(
      Uint8List.fromList(retrieved['encrypted_data'] as List<int>)
    );
    expect(decrypted, equals(originalData));
  });
}
Enter fullscreen mode Exit fullscreen mode

4. Integration Testing

// integration_test/privacy_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
  IntegrationTestWidgetsFlutterBinding.ensureInitialized();

  testWidgets('App functions completely offline', (tester) async {
    // Launch app
    // Verify it loads without network
    // Perform key operations (add document, encrypt, decrypt)
    // All should work without network access
  });
}
Enter fullscreen mode Exit fullscreen mode

The Monetization Question

"How do I make money if I don't track users?"

Simple: In-app purchases or subscriptions for features, not for data access.

For Proof Pocket, I use RevenueCat - a privacy-friendly monetization platform that handles subscriptions and one-time purchases without requiring your own backend.

import 'package:purchases_flutter/purchases_flutter.dart';
import 'package:purchases_ui_flutter/purchases_ui_flutter.dart';

class PurchaseManager {
  static const String revenueCatApiKey = 'your_revenuecat_api_key';

  // Initialize RevenueCat (call this on app start)
  static Future<void> initialize() async {
    await Purchases.setLogLevel(LogLevel.debug); // Only in debug

    PurchasesConfiguration configuration = PurchasesConfiguration(revenueCatApiKey);
    await Purchases.configure(configuration);
  }

  // Check if user has active subscription or lifetime access
  Future<bool> hasProAccess() async {
    try {
      CustomerInfo customerInfo = await Purchases.getCustomerInfo();

      // Check for active entitlements
      return customerInfo.entitlements.active.containsKey('pro');
    } catch (e) {
      print('Error checking pro access: $e');
      return false;
    }
  }

  // Show paywall using RevenueCat's UI
  Future<bool> showPaywall() async {
    try {
      final paywallResult = await RevenueCatUI.presentPaywall();

      // Check if purchase was successful
      if (paywallResult == PaywallResult.purchased) {
        return true;
      }

      return false;
    } catch (e) {
      print('Error showing paywall: $e');
      return false;
    }
  }

  // Manual purchase flow (if you want custom UI)
  Future<bool> purchaseProduct(String productId) async {
    try {
      // Get available offerings
      Offerings offerings = await Purchases.getOfferings();

      if (offerings.current == null) {
        print('No offerings available');
        return false;
      }

      // Find the product
      Package? package = offerings.current!.availablePackages
          .firstWhere(
            (p) => p.identifier == productId,
            orElse: () => throw Exception('Product not found'),
          );

      // Make purchase
      CustomerInfo customerInfo = await Purchases.purchasePackage(package);

      // Check if purchase was successful
      return customerInfo.entitlements.active.containsKey('pro');
    } catch (e) {
      print('Purchase error: $e');
      return false;
    }
  }

  // Restore purchases
  Future<bool> restorePurchases() async {
    try {
      CustomerInfo customerInfo = await Purchases.restorePurchases();
      return customerInfo.entitlements.active.containsKey('pro');
    } catch (e) {
      print('Restore error: $e');
      return false;
    }
  }

  // Get current offerings (products available)
  Future<Offerings?> getOfferings() async {
    try {
      return await Purchases.getOfferings();
    } catch (e) {
      print('Error getting offerings: $e');
      return null;
    }
  }
}

// Usage in your app
class VaultApp extends StatefulWidget {
  @override
  _VaultAppState createState() => _VaultAppState();
}

class _VaultAppState extends State<VaultApp> {
  final _purchaseManager = PurchaseManager();
  bool _hasProAccess = false;

  @override
  void initState() {
    super.initState();
    _checkProAccess();
  }

  Future<void> _checkProAccess() async {
    final hasAccess = await _purchaseManager.hasProAccess();
    setState(() {
      _hasProAccess = hasAccess;
    });
  }

  Future<void> _showUpgradeDialog() async {
    final purchased = await _purchaseManager.showPaywall();
    if (purchased) {
      await _checkProAccess();
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: _hasProAccess 
            ? ProFeatures() 
            : FreeFeatures(onUpgrade: _showUpgradeDialog),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Required packages:

dependencies:
  purchases_flutter: ^9.2.1
  purchases_ui_flutter: ^9.4.0  # Optional: for pre-built paywall UI
Enter fullscreen mode Exit fullscreen mode

Why RevenueCat for privacy-first apps:

  • ✅ No need to build your own backend
  • ✅ Handles App Store/Play Store validation
  • ✅ Works completely client-side (no server needed)
  • ✅ Privacy-focused (they don't sell your user data)
  • ✅ Anonymous user IDs (no email required)
  • ✅ Cross-platform (iOS & Android with one codebase)

Pricing Model:

  • Free tier: 2 encrypted documents
  • Yearly subscription: Unlimited documents + support
  • One-time lifetime purchase: Full access forever

Key Principle: Charge for features, not for access to user data. RevenueCat handles the complex subscription logic without requiring you to track users or build a backend.

Common Objections (And Responses)

"But users want cloud sync!"

Some do. But there's a market for people who value privacy over convenience. Target them.

You can also support manual export/import for cross-device usage without servers.

"How will I debug without crash reporting?"

Use local logging in debug builds. Encourage users to report issues manually (with their permission).

Note: Proof Pocket does use Sentry for crash reporting, but it's configured to be privacy-preserving - no user data is sent, only crash stack traces. This is a tradeoff between being completely offline and being able to fix bugs.

import 'package:flutter/foundation.dart';
import 'package:logging/logging.dart';

class DebugLogger {
  static final Logger _logger = Logger('ProofPocket');

  static void initialize() {
    // Only log in debug mode
    if (kDebugMode) {
      Logger.root.level = Level.ALL;
      Logger.root.onRecord.listen((record) {
        print('${record.level.name}: ${record.time}: ${record.message}');
        if (record.error != null) {
          print('Error: ${record.error}');
        }
        if (record.stackTrace != null) {
          print('Stack trace:\n${record.stackTrace}');
        }
      });
    }
  }

  static void log(String message, [Level level = Level.INFO]) {
    if (kDebugMode) {
      _logger.log(level, message);
    }
    // In release mode: nothing gets logged locally
  }

  static void error(String message, [Object? error, StackTrace? stackTrace]) {
    if (kDebugMode) {
      _logger.severe(message, error, stackTrace);
    }
  }

  static void warning(String message) {
    if (kDebugMode) {
      _logger.warning(message);
    }
  }

  static void info(String message) {
    if (kDebugMode) {
      _logger.info(message);
    }
  }
}

// Usage
try {
  await someOperation();
  DebugLogger.info('Operation completed successfully');
} catch (e, stackTrace) {
  DebugLogger.error('Operation failed', e, stackTrace);
  // In debug: logs to console
  // In release: silent (or sent to Sentry if configured)
}
Enter fullscreen mode Exit fullscreen mode

If using Sentry (privacy-preserving configuration):

import 'package:sentry_flutter/sentry_flutter.dart';

Future<void> main() async {
  await SentryFlutter.init(
    (options) {
      options.dsn = 'your-sentry-dsn';
      options.environment = kDebugMode ? 'debug' : 'production';

      // Privacy settings
      options.sendDefaultPii = false; // Don't send user info
      options.attachStacktrace = true; // Only stack traces

      // Don't capture user interactions or breadcrumbs
      options.enableAutoSessionTracking = false;

      // Filter out sensitive data
      options.beforeSend = (event, {hint}) {
        // Remove any potentially sensitive data from events
        event = event.copyWith(
          user: null, // Remove user info
          contexts: null, // Remove device context
        );
        return event;
      };
    },
    appRunner: () => runApp(MyApp()),
  );
}
Enter fullscreen mode Exit fullscreen mode

Required package:

dependencies:
  logging: ^1.3.0
  sentry_flutter: ^9.6.0  # Optional, for privacy-aware crash reporting
Enter fullscreen mode Exit fullscreen mode

"How will I know if anyone uses it?"

App Store Connect provides download numbers. That's all you need.

Feature usage can be tracked locally (on-device only) and shown to users in settings.

Conclusion

Building truly private apps requires intentional architectural decisions:

DO:

  • Use offline-first architecture
  • Store everything locally with encryption
  • Request minimal permissions
  • Leverage device-level security
  • Give users full data control
  • Be transparent about what you do (and don't do)

DON'T:

  • Integrate third-party SDKs without auditing them
  • Use cloud services "just because"
  • Track user behavior (even anonymously)
  • Assume users won't notice privacy violations

The tradeoff is real: You sacrifice analytics, cloud sync, and some convenience features.

The reward is also real: You build trust with privacy-conscious users, avoid data breach liability, and sleep better at night knowing you're not part of the surveillance capitalism machine.


Resources

Flutter & Dart Documentation:

Platform-Specific Security:

Privacy Best Practices:


About

I built Proof Pocket using these principles — an offline-first vault app for iOS and Android (built with Flutter) that stores encrypted documents without any server infrastructure. You can find it on the App Store and Google Play.

Questions? Disagree with something? Drop a comment below. I'd love to hear how other Flutter developers are approaching privacy-first app development.


Code examples are simplified for clarity. Always conduct security audits and testing for production apps.

Top comments (0)