diff --git a/CHANGELOG.md b/CHANGELOG.md index d92b494e..d58a607b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Change Log + +## 27.1.0-rc.8 + +* Added: `Analytics` service, the `Tracking` helper and `TrackingObserver`, which were missing from `27.1.0-rc.5` through `rc.7` +* Changed: `Tracking` takes the `Analytics` service and a property id instead of an emitter callback +* Changed: `enableAllAutoTracking()` is now `start()` +* Fixed: engagement time is no longer discarded when the app returns from `inactive` without backgrounding +* Fixed: a rejected tracking request no longer surfaces as an unhandled async error +* Fixed: removing a buried route no longer records a screen view for a screen that was never visible + ## 27.1.0-rc.7 * Fixed: `host()` reports the live connection state instead of always resolving open diff --git a/README.md b/README.md index a3c835ba..2cb000b2 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Add this to your package's `pubspec.yaml` file: ```yml dependencies: - appwrite: ^27.1.0-rc.7 + appwrite: ^27.1.0-rc.8 ``` You can install packages from the command line: diff --git a/android/build.gradle b/android/build.gradle index 8e542eb9..cdc5cb00 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,7 +1,7 @@ // The native half of Push on Android: background delivery that survives the process being // killed, a reboot and an app update, shared with the Appwrite Android SDK. group = "io.appwrite.flutter" -version = "27.1.0-rc.7" +version = "27.1.0-rc.8" buildscript { ext.kotlin_version = "2.1.0" diff --git a/docs/examples/account/create-o-auth-2-session.md b/docs/examples/account/create-o-auth-2-session.md index 75abae31..7c330fa4 100644 --- a/docs/examples/account/create-o-auth-2-session.md +++ b/docs/examples/account/create-o-auth-2-session.md @@ -13,5 +13,6 @@ await account.createOAuth2Session( success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional ); ``` diff --git a/docs/examples/account/create-o-auth-2-token.md b/docs/examples/account/create-o-auth-2-token.md index be098a62..44fd2f1d 100644 --- a/docs/examples/account/create-o-auth-2-token.md +++ b/docs/examples/account/create-o-auth-2-token.md @@ -13,5 +13,6 @@ await account.createOAuth2Token( success: 'https://example.com', // optional failure: 'https://example.com', // optional scopes: [], // optional + state: '', // optional ); ``` diff --git a/docs/examples/analytics/create-event.md b/docs/examples/analytics/create-event.md new file mode 100644 index 00000000..d88e257b --- /dev/null +++ b/docs/examples/analytics/create-event.md @@ -0,0 +1,25 @@ +```dart +import 'package:appwrite/appwrite.dart'; + +Client client = Client() + .setEndpoint('https://.cloud.appwrite.io/v1') // Your API Endpoint + .setProject(''); // Your project ID + +Analytics analytics = Analytics(client); + +models. result = await analytics.createEvent( + propertyId: '', + name: '', + url: 'https://example.com', + domain: '', // optional + referrer: '', // optional + screenWidth: 0, // optional + sessionHash: '', // optional + scrollDepth: 0, // optional + engagementTime: 0, // optional + props: [], // optional + userId: '', // optional + ip: '', // optional + userAgent: '', // optional +); +``` diff --git a/lib/appwrite.dart b/lib/appwrite.dart index c2083c8a..b2a90ed8 100644 --- a/lib/appwrite.dart +++ b/lib/appwrite.dart @@ -30,6 +30,8 @@ export 'src/upload_progress.dart'; export 'src/realtime_subscription.dart'; export 'src/realtime_message.dart'; export 'src/input_file.dart'; +export 'src/tracking.dart'; +export 'src/tracking_observer.dart'; part 'query.dart'; part 'permission.dart'; @@ -39,6 +41,7 @@ part 'topic.dart'; part 'channel.dart'; part 'operator.dart'; part 'services/account.dart'; +part 'services/analytics.dart'; part 'services/apps.dart'; part 'services/avatars.dart'; part 'services/databases.dart'; diff --git a/lib/services/account.dart b/lib/services/account.dart index 9eda7926..0c6fa360 100644 --- a/lib/services/account.dart +++ b/lib/services/account.dart @@ -1493,6 +1493,7 @@ class Account extends Service { String? success, String? failure, List? scopes, + String? state, }) async { final String apiPath = '/account/sessions/oauth2/{provider}'.replaceAll( '{provider}', @@ -1503,6 +1504,7 @@ class Account extends Service { if (success != null) 'success': success, if (failure != null) 'failure': failure, if (scopes != null) 'scopes': scopes, + if (state != null) 'state': state, 'project': client.config['project'], }; @@ -1945,6 +1947,7 @@ class Account extends Service { String? success, String? failure, List? scopes, + String? state, }) async { final String apiPath = '/account/tokens/oauth2/{provider}'.replaceAll( '{provider}', @@ -1955,6 +1958,7 @@ class Account extends Service { if (success != null) 'success': success, if (failure != null) 'failure': failure, if (scopes != null) 'scopes': scopes, + if (state != null) 'state': state, 'project': client.config['project'], }; diff --git a/lib/services/analytics.dart b/lib/services/analytics.dart new file mode 100644 index 00000000..effdd8e6 --- /dev/null +++ b/lib/services/analytics.dart @@ -0,0 +1,65 @@ +part of '../appwrite.dart'; + +class Analytics extends Service { + /// Initializes a [Analytics] service + Analytics(super.client); + + /// Send a tracking event from a browser, native app, or server-side SDK. + Future createEvent({ + required String propertyId, + required String name, + required String url, + String? domain, + String? referrer, + int? screenWidth, + String? sessionHash, + int? scrollDepth, + int? engagementTime, + List? props, + String? userId, + String? ip, + String? userAgent, + }) async { + if (propertyId.isEmpty) { + throw AppwriteException( + 'Missing required parameter: "propertyId"', + ); + } + + final String apiPath = '/analytics/properties/{propertyId}/events' + .replaceAll( + '{propertyId}', + propertyId, + ); + + final Map apiParams = { + 'name': name, + 'url': url, + if (domain != null) 'domain': domain, + if (referrer != null) 'referrer': referrer, + if (screenWidth != null) 'screenWidth': screenWidth, + if (sessionHash != null) 'sessionHash': sessionHash, + if (scrollDepth != null) 'scrollDepth': scrollDepth, + if (engagementTime != null) 'engagementTime': engagementTime, + if (props != null) 'props': props, + if (userId != null) 'userId': userId, + if (ip != null) 'ip': ip, + if (userAgent != null) 'userAgent': userAgent, + }; + + final Map apiHeaders = { + 'X-Appwrite-Project': client.config['project'] ?? '', + 'content-type': 'application/json', + 'accept': 'application/json', + }; + + final res = await client.call( + HttpMethod.post, + path: apiPath, + params: apiParams, + headers: apiHeaders, + ); + + return res.data; + } +} diff --git a/lib/src/client_browser.dart b/lib/src/client_browser.dart index e8f4ada5..3db8e883 100644 --- a/lib/src/client_browser.dart +++ b/lib/src/client_browser.dart @@ -40,7 +40,7 @@ class ClientBrowser extends ClientBase with ClientMixin { 'x-sdk-name': 'Flutter', 'x-sdk-platform': 'client', 'x-sdk-language': 'flutter', - 'x-sdk-version': '27.1.0-rc.7', + 'x-sdk-version': '27.1.0-rc.8', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/lib/src/client_io.dart b/lib/src/client_io.dart index ebfc79c1..bb9f7a00 100644 --- a/lib/src/client_io.dart +++ b/lib/src/client_io.dart @@ -60,7 +60,7 @@ class ClientIO extends ClientBase with ClientMixin { 'x-sdk-name': 'Flutter', 'x-sdk-platform': 'client', 'x-sdk-language': 'flutter', - 'x-sdk-version': '27.1.0-rc.7', + 'x-sdk-version': '27.1.0-rc.8', 'X-Appwrite-Response-Format': '2.3.0', }; diff --git a/lib/src/tracking.dart b/lib/src/tracking.dart new file mode 100644 index 00000000..e34a657a --- /dev/null +++ b/lib/src/tracking.dart @@ -0,0 +1,202 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../appwrite.dart' show Analytics; + +/// Auto-tracking helpers for the generated [Analytics] service: app-lifecycle +/// events (backgrounded / foregrounded) and manual `screenView` / `event` +/// calls, all sent through `Analytics.createEvent`. +/// +/// Auto-emitted event names follow `snake_case` + lowercase (`screen_view`, +/// `app_backgrounded`, `app_foregrounded`, etc.). Prop keys use camelCase to +/// match the endpoint's parameter naming. +/// +/// Attach a [TrackingObserver] to your `MaterialApp` for automatic route +/// tracking. +/// +/// [enableAutoLifecycleEvents] calls [WidgetsFlutterBinding.ensureInitialized] +/// internally, so it is safe to invoke before [runApp] without wiring up the +/// binding manually. Callers who need the binding for their own initialization +/// (async setup, plugin channels, etc.) can still call `ensureInitialized()` +/// themselves — the call is idempotent. +/// +/// Engagement time is reported the same way the Web helper reports it: as a +/// **delta** attached to each `app_backgrounded` event, covering only the +/// foreground time accrued since the last resume. Backgrounding is the mobile +/// equivalent of a browser tab going hidden, and it is where the Web helper +/// flushes too, so the two platforms feed the same additive `engagementTime` +/// column. The seconds ride on the lifecycle event that is already being sent +/// at that exact moment rather than adding a second request for a single +/// number. +/// +/// Typical wiring: +/// +/// ```dart +/// final tracking = Tracking(Analytics(client), ''); +/// tracking.start(); +/// +/// runApp(MaterialApp( +/// navigatorObservers: [TrackingObserver(tracking)], +/// home: MyApp(), +/// )); +/// ``` +class Tracking with WidgetsBindingObserver { + final Analytics _analytics; + + /// Analytics property every event is recorded against. + final String propertyId; + + /// Base URL events are reported under; screen names resolve against it. + /// Defaults to the page origin on Flutter web and `app://` + /// elsewhere, since the endpoint only accepts absolute URLs. + final String url; + + bool _lifecycleAttached = false; + DateTime? _foregroundSince; + + /// Sub-second engagement left over from the previous flush. Carried forward + /// so a session made of many short foreground stretches does not lose a + /// fraction of a second to truncation on every one of them. + Duration _engagementCarry = Duration.zero; + + Tracking(Analytics analytics, this.propertyId, {String? url}) + : _analytics = analytics, + url = + url ?? + (kIsWeb + ? Uri.base.origin + : 'app://${defaultTargetPlatform.name.toLowerCase()}'); + + /// Start the default auto-tracking set: app-lifecycle events, as + /// [enableAutoLifecycleEvents]. Route tracking is opt-in via + /// [TrackingObserver]. + void start() { + enableAutoLifecycleEvents(); + } + + /// Start emitting `app_backgrounded` and `app_foregrounded` events when the + /// host app changes lifecycle state. Idempotent — repeat calls no-op. + /// + /// Calls [WidgetsFlutterBinding.ensureInitialized] internally so this is + /// safe to invoke before [runApp] without the caller having to bootstrap + /// the binding themselves. + void enableAutoLifecycleEvents() { + if (_lifecycleAttached) { + return; + } + // WidgetsBinding.instance throws StateError if the binding has not been + // set up yet. The docstring example wires this call before runApp(), so + // ensure the binding here — the call is idempotent. + WidgetsFlutterBinding.ensureInitialized(); + _lifecycleAttached = true; + _foregroundSince = DateTime.now(); + _engagementCarry = Duration.zero; + WidgetsBinding.instance.addObserver(this); + } + + /// Stop emitting lifecycle events. Safe to call when auto-lifecycle was + /// never enabled. + void disableAutoLifecycleEvents() { + if (!_lifecycleAttached) { + return; + } + _lifecycleAttached = false; + _foregroundSince = null; + _engagementCarry = Duration.zero; + WidgetsBinding.instance.removeObserver(this); + } + + /// Emit an ad-hoc analytics event. [url] may be absolute or a path resolved + /// against [Tracking.url]. Request failures are swallowed so analytics never + /// surface errors to the host app. + void event( + String name, { + String? url, + Map? props, + int? engagementTime, + }) { + try { + unawaited( + _analytics + .createEvent( + propertyId: propertyId, + name: name, + url: + url == null + ? this.url + : Uri.parse(this.url).resolve(url).toString(), + engagementTime: engagementTime, + // The endpoint takes props as a flat alternating key/value list. + props: + props?.entries + .expand((entry) => [entry.key, '${entry.value}']) + .toList(), + ) + .catchError((Object _) {}), + ); + } catch (_) { + // A malformed [url] throws before the request starts. + } + } + + /// Mobile-idiomatic screen-view shortcut. Renders as a `screen_view` + /// analytics event with `screen`, optional `screenClass` and any + /// caller-supplied properties. [name] is also the event URL's path. + void screenView( + String name, { + String? className, + Map? props, + }) { + final merged = {'screen': name}; + if (className != null) { + merged['screenClass'] = className; + } + if (props != null) { + merged.addAll(props); + } + event('screen_view', url: name, props: merged); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + // On Flutter desktop and web the lifecycle transitions through both + // `hidden` and `paused` when the window is minimised / closed. Guard + // on `_foregroundSince` so we emit `app_backgrounded` exactly once + // per background transition instead of firing a second empty event — + // which is also what keeps the engagement delta from being billed + // twice for one transition. + final since = _foregroundSince; + if (since == null) { + break; + } + _foregroundSince = null; + final elapsed = _engagementCarry + DateTime.now().difference(since); + final seconds = elapsed.inSeconds; + _engagementCarry = elapsed - Duration(seconds: seconds); + event( + 'app_backgrounded', + engagementTime: seconds > 0 ? seconds : null, + ); + break; + case AppLifecycleState.resumed: + // `inactive` -> `resumed` (notification shade, app switcher preview) + // never reaches `hidden`/`paused`, so the interval is still open. + // Restarting it would discard the engagement accrued before the + // interruption and emit a foreground event for no background. + if (_foregroundSince != null) { + break; + } + _foregroundSince = DateTime.now(); + event('app_foregrounded'); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + break; + } + } +} diff --git a/lib/src/tracking_observer.dart b/lib/src/tracking_observer.dart new file mode 100644 index 00000000..43b9c180 --- /dev/null +++ b/lib/src/tracking_observer.dart @@ -0,0 +1,112 @@ +import 'package:flutter/widgets.dart'; + +import 'tracking.dart'; + +/// Signature used to derive a screen name from a [Route]. +/// +/// Return `null` to skip tracking the route entirely (for example, dialogs or +/// bottom sheets that shouldn't count as screen views). +typedef ScreenNameExtractor = String? Function(Route route); + +/// Default [ScreenNameExtractor] — uses [RouteSettings.name] when present and +/// falls back to `null` (route is skipped) otherwise. Anonymous routes are +/// generally noise for analytics, so opting out by default keeps dashboards +/// clean. +String? defaultScreenNameExtractor(Route route) { + return route.settings.name; +} + +/// [NavigatorObserver] that fires an analytics event through a [Tracking] +/// every time a named route is pushed, popped-back-to, replaced or removed. +/// +/// Attach to a `MaterialApp` (or `CupertinoApp`, `WidgetsApp`) via +/// `navigatorObservers`: +/// +/// ```dart +/// MaterialApp( +/// navigatorObservers: [TrackingObserver(tracking)], +/// ... +/// ); +/// ``` +class TrackingObserver extends NavigatorObserver { + /// Tracker the resulting `screen_view` events are sent through. + final Tracking tracking; + + /// Optional route-name extractor. Defaults to [defaultScreenNameExtractor] + /// which reads [RouteSettings.name]. + final ScreenNameExtractor nameExtractor; + + /// Event name used for screen views. Defaults to `screen_view`. + final String eventName; + + TrackingObserver( + this.tracking, { + ScreenNameExtractor? nameExtractor, + this.eventName = 'screen_view', + }) : nameExtractor = nameExtractor ?? defaultScreenNameExtractor; + + /// The route the user is actually looking at. `didRemove` fires for buried + /// routes too, and its `previousRoute` is the route below the removed one + /// rather than the visible one, so the top has to be tracked explicitly. + Route? _topRoute; + + @override + void didPush(Route route, Route? previousRoute) { + _topRoute = route; + _sendScreenView(route, previousRoute, trigger: 'push'); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + if (newRoute == null) { + return; + } + if (oldRoute != null && !identical(oldRoute, _topRoute)) { + // A replace further down the stack leaves the visible screen alone. + return; + } + _topRoute = newRoute; + _sendScreenView(newRoute, oldRoute, trigger: 'replace'); + } + + @override + void didPop(Route route, Route? previousRoute) { + _topRoute = previousRoute; + if (previousRoute != null) { + _sendScreenView(previousRoute, route, trigger: 'pop'); + } + } + + @override + void didRemove(Route route, Route? previousRoute) { + if (!identical(route, _topRoute)) { + // Removing a buried route never changes what is on screen. + return; + } + _topRoute = previousRoute; + if (previousRoute != null) { + _sendScreenView(previousRoute, route, trigger: 'remove'); + } + } + + void _sendScreenView( + Route route, + Route? previousRoute, { + required String trigger, + }) { + final name = nameExtractor(route); + if (name == null || name.isEmpty) { + return; + } + final props = { + 'screen': name, + 'trigger': trigger, + }; + final previousName = + previousRoute == null ? null : nameExtractor(previousRoute); + if (previousName != null && previousName.isNotEmpty) { + props['previous'] = previousName; + } + tracking.event(eventName, url: name, props: props); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index c3e3eee8..edc34a29 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: appwrite -version: 27.1.0-rc.7 +version: 27.1.0-rc.8 description: Appwrite is an open-source self-hosted backend server that abstracts and simplifies complex and repetitive development tasks behind a very simple REST API homepage: https://appwrite.io repository: https://github.com/appwrite/sdk-for-flutter diff --git a/test/services/analytics_test.dart b/test/services/analytics_test.dart new file mode 100644 index 00000000..d94506aa --- /dev/null +++ b/test/services/analytics_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:appwrite/models.dart' as models; +import 'package:appwrite/enums.dart' as enums; +import 'package:appwrite/src/enums.dart'; +import 'package:appwrite/src/response.dart'; +import 'dart:typed_data'; +import 'package:appwrite/appwrite.dart'; + +class MockClient extends Mock implements Client { + Map config = {'project': 'testproject'}; + String endPoint = 'https://localhost/v1'; + + @override + Future call( + HttpMethod? method, { + String path = '', + Map headers = const {}, + Map params = const {}, + ResponseType? responseType, + }) async { + return super.noSuchMethod( + Invocation.method(#call, [method]), + returnValue: Response(), + ); + } + + @override + Future webAuth(Uri? url, {String? callbackUrlScheme}) async { + return super.noSuchMethod( + Invocation.method(#webAuth, [url]), + returnValue: 'done', + ); + } + + @override + Future chunkedUpload({ + String? path, + Map? params, + String? paramName, + String? idParamName, + Map? headers, + Function(UploadProgress)? onProgress, + ResponseType? responseType, + HttpMethod method = HttpMethod.post, + }) async { + return super.noSuchMethod( + Invocation.method(#chunkedUpload, [ + path, + params, + paramName, + idParamName, + headers, + ]), + returnValue: Response(data: {}), + ); + } +} + +void main() { + group('Analytics test', () { + late MockClient client; + late Analytics analytics; + + setUp(() { + client = MockClient(); + analytics = Analytics(client); + }); + + test('test method createEvent()', () async { + final data = ''; + + when( + client.call(HttpMethod.post), + ).thenAnswer((_) async => Response(data: data)); + + final response = await analytics.createEvent( + propertyId: "", + name: "", + url: "https://example.com", + ); + }); + }); +}