Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/atomic-flutter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Atomic Flutter package
on:
pull_request:
paths: ['packages/atomic_flutter/**', 'flutter/**', '.github/workflows/atomic-flutter.yml']
push:
paths: ['packages/atomic_flutter/**', 'flutter/**', '.github/workflows/atomic-flutter.yml']
jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.44.7'
channel: stable
cache: true
- run: flutter pub get
working-directory: packages/atomic_flutter
- run: flutter analyze
working-directory: packages/atomic_flutter
- run: flutter test
working-directory: packages/atomic_flutter
10 changes: 10 additions & 0 deletions TESTING_COVERAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2380,3 +2380,13 @@ refused subscribe is dropped.
- The paired SaaS `portal/e2e/onboarding.spec.ts` closes the original context,
downloads the vault into a fresh browser, verifies the saved document and
profile, requires a clean console, and budgets metadata reads after reload.

## Flutter account package (draft)

`packages/atomic_flutter/test/account_test.dart` covers device approval, provider
origin binding, redirect refusal, account/backup mismatch, authenticated discovery,
AES-GCM envelope compatibility with an independently generated Node fixture,
tamper rejection, and closing during a pending request. Atomic Audio additionally
checks isolated native identity installation and actual bidirectional iroh/Loro
with BLAKE3 files introduced after pairing. Production account approval, Vault
transport and recovery on a physical device are not covered by these tests.
5 changes: 5 additions & 0 deletions flutter/analysis_options.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,8 @@ linter:

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

# Cargokit is a vendored standalone Dart package, checked in its own context.
analyzer:
exclude:
- rust_builder/cargokit/**
21 changes: 15 additions & 6 deletions flutter/integration_test/simple_test.dart
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:atomiccanvas_flutter/main.dart';
import 'dart:io';
import 'package:atomiccanvas_flutter/atomic/atomic_client.dart';
import 'package:atomiccanvas_flutter/atomic/settings_backend.dart';
import 'package:atomiccanvas_flutter/src/rust/frb_generated.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';

void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() async => await RustLib.init());
testWidgets('Can call rust function', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.textContaining('Result: `Hello, Tom!`'), findsOneWidget);
testWidgets('settings backend reads a native Atomic identity and drive',
(tester) async {
await RustLib.init();
final dir = await Directory.systemTemp.createTemp('atomic-settings-test-');
addTearDown(() => dir.delete(recursive: true));
await AtomicClient.openDb(dir.path);
final account = await AtomicClient.setup('Package test');
final backend = CanvasSettingsBackend();
expect((await backend.getActiveAgent())?.subject, account.agentSubject);
expect(await backend.listDrives(), contains(account.driveSubject));
expect(await backend.exportSecret(), account.agentSecret);
});
}
131 changes: 131 additions & 0 deletions flutter/lib/atomic/settings_backend.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:atomic_flutter/atomic_flutter.dart' as shared;
import 'atomic_client.dart';
import 'atomic_auth.dart';
import 'server_info.dart';
import 'session.dart';
import '../screens/pair_screen.dart';

/// Canvas keeps its existing native bridge, secure storage and transport.
/// Only the UI is shared with other Atomic applications.
class CanvasSettingsBackend extends shared.AtomicSettingsBackend {
@override
Future<shared.AtomicIdentity?> getActiveAgent() async {
final agent = await AtomicClient.getActiveAgent();
return agent == null
? null
: shared.AtomicIdentity(subject: agent.subject, name: agent.name);
}

@override
Future<String> exportSecret() async {
final agent = await AtomicClient.getActiveAgent();
if (agent == null) throw StateError('No active identity');
return agent.secret;
}

@override
Future<List<String>> listDrives() => AtomicClient.listDrives();
@override
String? getActiveDrive() => AtomicClient.getActiveDrive();
@override
Future<String> getDriveName(String drive) =>
AtomicClient.getProperty(drive, 'https://atomicdata.dev/properties/name');
@override
Future<void> createDrive(String name) async {
await AtomicClient.createDrive(name);
}

@override
Future<void> switchDrive(String drive) async {
await AtomicClient.setActiveDrive(drive);
await AtomicSession.saveDrive(drive);
}

@override
Future<String?> getPeerId() => AtomicClient.getPeerId();
@override
Future<String> getDeviceName() async {
var name = await AtomicClient.getDeviceName();
if (name.isEmpty) {
name = await PairScreen.getDeviceName();
if (name.isNotEmpty && name != 'localhost') {
await AtomicClient.setDeviceName(name);
}
}
return name;
}

@override
Future<void> setDeviceName(String name) => AtomicClient.setDeviceName(name);
@override
Future<List<Map<String, String>>> getKnownPeers() =>
AtomicClient.getKnownPeers();
@override
Future<Set<String>> livePeerIds() async => AtomicClient.livePeerIds();
@override
Future<void> removeKnownPeer(String nodeId) =>
AtomicClient.removeKnownPeer(nodeId);
@override
Future<void> pair(BuildContext context) async {
await PairScreen.show(context);
}

@override
shared.AtomicServerBackend get servers => CanvasServerBackend();
@override
Future<void> Function() get signOut => AtomicSession.clear;
}

class CanvasServerBackend extends shared.AtomicServerBackend {
@override
Future<List<String>> knownServers() => AtomicSession.knownServers();
@override
Future<String?> activeServer() => AtomicSession.activeServer();
@override
Future<shared.ServerInfo> serverInfo(String url) async {
final info = await fetchServerInfo(url);
return shared.ServerInfo(
nodeId: info.nodeId,
version: info.version,
managed: info.managed,
portalUrl: info.portalUrl);
}

@override
Future<shared.DriveUsage?> driveUsage(String url) async {
final session = await AtomicSession.load();
if (session == null) return null;
final drive = AtomicClient.getActiveDrive() ?? session.drive;
if (drive == null) return null;
final usage = await fetchDriveUsage(
url, drive, AtomicAgent.fromSecret(session.secret));
return usage == null
? null
: shared.DriveUsage(
driveName: usage.driveName,
resourceCount: usage.resourceCount,
blobBytes: usage.blobBytes,
loroBytes: usage.loroBytes);
}

@override
Future<void> switchTo(String url) async {
await AtomicClient.closeWsSync();
await AtomicSession.setActiveServer(url);
await AtomicClient.openWsSync(url);
}

@override
Future<void> add(String url) => AtomicSession.addKnownServer(url);
@override
Future<void> remove(String url) async {
if (shared.sameOrigin(url, await AtomicSession.activeServer())) {
await AtomicClient.closeWsSync();
}
await AtomicSession.removeKnownServer(url);
}

@override
Future<int> pushWorkspace(String url) => AtomicClient.syncDriveToServer(url);
}
Loading
Loading