Skip to content
Merged
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
2 changes: 2 additions & 0 deletions src/SDK/Language/Flutter.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,11 +324,13 @@ public function getFiles(): array
[
'scope' => 'default',
'destination' => '/lib/src/analytics_observer.dart',
'requires' => 'analytics',
'template' => 'flutter/lib/src/analytics_observer.dart.twig',
],
[
'scope' => 'default',
'destination' => '/lib/src/analytics_tracking.dart',
'requires' => 'analytics',
'template' => 'flutter/lib/src/analytics_tracking.dart.twig',
],
[
Expand Down
1 change: 1 addition & 0 deletions src/SDK/Language/Web.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public function getFiles(): array
[
'scope' => 'default',
'destination' => 'src/services/analytics-tracking.ts',
'requires' => 'analytics',
'template' => 'web/src/services/analytics-tracking.ts.twig',
],
[
Expand Down
7 changes: 7 additions & 0 deletions src/SDK/SDK.php
Original file line number Diff line number Diff line change
Expand Up @@ -1012,6 +1012,13 @@ public function generate(string $target): void
continue;
}

// A companion template that references a generated service must not
// ship when the spec carries no such service, or it names a class
// the SDK never generates and the package stops compiling.
if (isset($file['requires']) && !isset($filteredServices[$file['requires']])) {
continue;
Comment thread
hansi-codes[bot] marked this conversation as resolved.
}
Comment thread
hansi-codes[bot] marked this conversation as resolved.

if (!\in_array($file['scope'], ['copy', 'download'], true)) {
$template = $this->twig->load($file['template']); /* @var $template TemplateWrapper */
}
Expand Down
2 changes: 2 additions & 0 deletions templates/flutter/lib/package.dart.twig
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export 'src/upload_progress.dart';
export 'src/realtime_subscription.dart';
export 'src/realtime_message.dart';
export 'src/input_file.dart';
{% if services['analytics'] is defined %}
export 'src/analytics_observer.dart';
export 'src/analytics_tracking.dart';
{% endif %}

part 'query.dart';
part 'permission.dart';
Expand Down
35 changes: 31 additions & 4 deletions templates/flutter/lib/src/analytics_observer.dart.twig
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/widgets.dart';

/// Signature used to emit an analytics event from an [AnalyticsObserver] or
Expand Down Expand Up @@ -27,7 +29,7 @@ import 'package:flutter/widgets.dart';
/// [propertyId] and [engagementTime] map to the endpoint's top-level params of
/// the same name; both are null unless the helper has something to say about
/// them, so a closure is free to ignore either.
typedef AnalyticsEventEmitter = void Function(
typedef AnalyticsEventEmitter = FutureOr<void> Function(
String name, {
Map<String, dynamic>? props,
String? propertyId,
Expand Down Expand Up @@ -96,27 +98,45 @@ class AnalyticsObserver extends NavigatorObserver {
this.propertyId,
}) : 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<dynamic>? _topRoute;

@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
_topRoute = route;
_sendScreenView(route, previousRoute, trigger: 'push');
}

@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
if (newRoute != null) {
_sendScreenView(newRoute, oldRoute, trigger: 'replace');
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<dynamic> route, Route<dynamic>? previousRoute) {
_topRoute = previousRoute;
if (previousRoute != null) {
_sendScreenView(previousRoute, route, trigger: 'pop');
}
}

@override
void didRemove(Route<dynamic> route, Route<dynamic>? 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');
}
Expand All @@ -140,7 +160,14 @@ class AnalyticsObserver extends NavigatorObserver {
props['previous'] = previousName;
}
try {
emit(eventName, props: props, propertyId: propertyId);
// The emitter usually returns the request future; without catching it a
// rejected request surfaces as an unhandled async error in the host app.
final pending = emit(eventName, props: props, propertyId: propertyId);
if (pending is Future) {
// `catchError` on a typed future must return that future's type, so an
// empty handler would itself throw. Narrow to `Future<void>` first.
unawaited(pending.then<void>((_) {}).catchError((Object _) {}));
}
} catch (_) {
// Emitter failures must never propagate into the navigator stack —
// swallow so analytics can't crash the app.
Expand Down
18 changes: 17 additions & 1 deletion templates/flutter/lib/src/analytics_tracking.dart.twig
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'dart:async';

import 'package:flutter/widgets.dart';

import 'analytics_observer.dart';
Expand Down Expand Up @@ -123,12 +125,19 @@ class AnalyticsTracking with WidgetsBindingObserver {
int? engagementTime,
}) {
try {
_emit(
// The emitter usually returns the request future; without catching it a
// rejected request surfaces as an unhandled async error in the host app.
final pending = _emit(
name,
props: props,
propertyId: propertyId,
engagementTime: engagementTime,
);
if (pending is Future) {
// `catchError` on a typed future must return that future's type, so an
// empty handler would itself throw. Narrow to `Future<void>` first.
unawaited(pending.then<void>((_) {}).catchError((Object _) {}));
}
} catch (_) {
// Silent — see class docs.
}
Expand Down Expand Up @@ -177,6 +186,13 @@ class AnalyticsTracking with WidgetsBindingObserver {
);
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;
Expand Down
4 changes: 4 additions & 0 deletions templates/web/src/index.ts.twig
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ export { {{service.name | caseUcfirst}} } from './services/{{service.name | case
{% endfor %}
export { Realtime } from './services/realtime';
export { Push } from './services/push';
{% if services['analytics'] is defined %}
export { AnalyticsTracking } from './services/analytics-tracking';
{% endif %}
export type {
Models,
Payload,
Expand All @@ -27,13 +29,15 @@ export type {
SubscribeOptions,
MessageCallback,
} from './services/push';
{% if services['analytics'] is defined %}
export type {
AnalyticsEventEmitter,
AnalyticsEventOptions,
AnalyticsTrackingOptions,
DownloadTrackingOptions,
OutboundTrackingOptions,
} from './services/analytics-tracking';
{% endif %}
export type { QueryTypes, QueryTypesList } from './query';
export { Permission } from './permission';
export { Role } from './role';
Expand Down
70 changes: 70 additions & 0 deletions tests/generation/GenerationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,76 @@ public function testFixtureSelection(string $name): void
}
}

/**
* The analytics tracking helpers are hand-written companions to a generated
* service. Shipping them into an SDK whose spec has no analytics service
* leaves files, and entry-point exports, naming a class that is never
* generated — which is what broke appwrite/sdk-for-flutter#336.
*/
public function testAnalyticsCompanionsFollowTheService(): void
{
$companions = [
'flutter' => ['lib/src/analytics_observer.dart', 'lib/src/analytics_tracking.dart'],
'web' => ['src/services/analytics-tracking.ts'],
];
$entrypoints = [
'flutter' => ['lib/packageName.dart', 'analytics_observer.dart'],
'web' => ['src/index.ts', 'analytics-tracking'],
];

foreach ($companions as $name => $paths) {
// The shared fixture carries no analytics service.
$without = $this->generate($name, 'client');
foreach ($paths as $path) {
$this->assertArrayNotHasKey($path, $without, "{$name} shipped {$path} without the analytics service");
}
[$entry, $token] = $entrypoints[$name];
$this->assertArrayHasKey($entry, $without, "{$name} did not generate {$entry}");
$this->assertStringNotContainsString($token, $without[$entry], "{$name} {$entry} exports a companion it did not generate");

$with = $this->generateWithAnalytics($name);
foreach ($paths as $path) {
$this->assertArrayHasKey($path, $with, "{$name} did not ship {$path} alongside the analytics service");
}
$this->assertStringContainsString($token, $with[$entry], "{$name} {$entry} does not export the companion");
}
}

/**
* @return array<string, string>
*/
private function generateWithAnalytics(string $name): array
{
$dir = self::OUTPUT . '/' . $name . '/analytics';
$this->removeDirectory($dir);

$document = \json_decode((string) \file_get_contents(self::FIXTURE), flags: JSON_THROW_ON_ERROR);
$operation = clone $document->paths->{'/ping'}->get;
$operation->tags = ['analytics'];
$operation->operationId = 'analyticsCreateEvent';
$document->paths->{'/analytics/event'} = (object) ['get' => $operation];
$document->tags[] = (object) ['name' => 'analytics', 'description' => 'Analytics'];

$sdk = new SDK($this->language($name), Parser::parse(\json_encode($document, JSON_THROW_ON_ERROR)));
$sdk
->setName('test')
->setVersion('0.0.1')
->setPlatform('client')
->setNamespace('appwrite')
->setTest('true')
->generate($dir);

$files = [];
$iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
foreach ($iterator as $file) {
if ($file->isFile()) {
$files[\substr((string) $file->getPathname(), \strlen($dir) + 1)] = \strtolower((string) \file_get_contents($file->getPathname()));
}
}

return $files;
}

public function testJavascriptClientOwnership(): void
{
$targets = [
Expand Down
Loading