Skip to content
Open
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
4 changes: 2 additions & 2 deletions lib/spendable_web/api/controllers/bank_member_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ defmodule SpendableWeb.Api.BankMemberController do
operation_id: "syncBank",
summary: "Pull two years of history",
description: """
Queues the work and returns immediately. There is no completion signal - refresh the lists to
pick up whatever has landed.
Queues the work and returns immediately. A silent push goes out when the run finishes, which
is the signal to re-read whatever the client is showing.
""",
parameters: [id: [in: :path, type: :string, required: true]],
responses: [
Expand Down
7 changes: 6 additions & 1 deletion mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,16 @@ or every regeneration will show up as a diff.

## Setup this repo cannot do for you

Both are external to the codebase and sign-in fails on device without them.
All external to the codebase. The first two fail sign-in on device; the third silently sends no
pushes.

- **Sign in with Apple** needs the capability enabled on `fiftysevenmedia.Spendable` in the Apple
Developer portal. `ios/Runner/Runner.entitlements` already declares it.
- **Google Sign-In** works on device from `ios/Runner/Info.plist`, which carries the iOS client id
and its reversed form as a URL scheme. The **server** needs the same value in its
`GOOGLE_IOS_CLIENT_ID` env var, or it rejects the app's ID tokens on audience - `scripts/install.sh`
prompts for it.
- **Push notifications** need the capability enabled on the same App ID and an APNs key (a `.p8`)
created under Keys. The **server** reads it, its key id and the team id from `.secrets/` -
`scripts/install.sh` prompts for all three. There is no push on the simulator: APNs issues no
device token there, and registration fails as a matter of course.
2 changes: 1 addition & 1 deletion mobile/api/lib/src/api/banks_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ class BanksApi {
}

/// Pull two years of history
/// Queues the work and returns immediately. There is no completion signal - refresh the lists to pick up whatever has landed.
/// Queues the work and returns immediately. A silent push goes out when the run finishes, which is the signal to re-read whatever the client is showing.
///
/// Parameters:
/// * [id] -
Expand Down
4 changes: 4 additions & 0 deletions mobile/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
7A19C4022F00000100C636F2 /* PushChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A19C4012F00000100C636F2 /* PushChannel.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
Expand Down Expand Up @@ -50,6 +51,7 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7A19C4012F00000100C636F2 /* PushChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushChannel.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
Expand Down Expand Up @@ -123,6 +125,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
7A19C4012F00000100C636F2 /* PushChannel.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
Expand Down Expand Up @@ -284,6 +287,7 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
7A19C4022F00000100C636F2 /* PushChannel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
13 changes: 13 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import Flutter
import UIKit
import UserNotifications

@main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var push: PushChannel?

override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Flutter forwards notification centre events to its life cycle delegates only while the app
// delegate is the centre's delegate.
UNUserNotificationCenter.current().delegate = self

return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)

let push = PushChannel(messenger: engineBridge.applicationRegistrar.messenger())

addApplicationLifeCycleDelegate(push)

self.push = push
}
}
5 changes: 5 additions & 0 deletions mobile/ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- A silent push is what tells the app a sync finished, and iOS drops it without this. -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
Expand Down
86 changes: 86 additions & 0 deletions mobile/ios/Runner/PushChannel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import Flutter
import UIKit
import UserNotifications

/// The device end of push, kept to plumbing: what a notification means is Dart's business.
///
/// Registered as a Flutter application life cycle delegate rather than written onto the app
/// delegate, so plugins keep receiving the same callbacks.
class PushChannel: NSObject, FlutterApplicationLifeCycleDelegate {
private let channel: FlutterMethodChannel

init(messenger: FlutterBinaryMessenger) {
channel = FlutterMethodChannel(name: "spendable/push", binaryMessenger: messenger)

super.init()

channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "register" else {
result(FlutterMethodNotImplemented)

return
}

self?.register(result)
}
}

func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
channel.invokeMethod("token", arguments: deviceToken.map { String(format: "%02x", $0) }.joined())
}

func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
// The simulator has no APNs, so this is the ordinary path there and not worth surfacing.
NSLog("push registration failed: %@", error.localizedDescription)
}

/// A silent push. iOS gives the app seconds rather than a callback to wait on, so the fetch
/// result is reported as soon as Dart has been told.
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) -> Bool {
channel.invokeMethod("refresh", arguments: nil)
completionHandler(.newData)

return true
}

/// An alert that arrived while the app was open: show it, and refresh behind it.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
channel.invokeMethod("refresh", arguments: nil)
completionHandler([.banner, .sound])
}

func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
channel.invokeMethod("opened", arguments: nil)
completionHandler()
}

private func register(_ result: @escaping FlutterResult) {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { granted, _ in
DispatchQueue.main.async {
// Registered whatever the answer: a silent push needs no permission, and it is what
// refreshes the app in the background.
UIApplication.shared.registerForRemoteNotifications()

result(granted)
}
}
}
}
4 changes: 4 additions & 0 deletions mobile/ios/Runner/Runner.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Xcode rewrites this to production when archiving against a distribution profile, so the
development value is what a checkout needs and not what ships. -->
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
Expand Down
5 changes: 4 additions & 1 deletion mobile/lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth/auth_controller.dart';
import 'auth/sign_in_screen.dart';
import 'banks/plaid_oauth_links.dart';
import 'push/push_controller.dart';
import 'shell.dart';
import 'theme.dart';

Expand All @@ -14,8 +15,10 @@ class SpendableApp extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authStateProvider);

// Nothing renders it; it just has to be alive to catch a bank's OAuth redirect on launch.
// Nothing renders these; they just have to be alive to catch a bank's OAuth redirect on
// launch, and to answer what APNs sends.
ref.watch(plaidOAuthResumeProvider);
ref.watch(pushControllerProvider);

return MaterialApp(
title: 'Spendable',
Expand Down
3 changes: 2 additions & 1 deletion mobile/lib/banks/banks_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ class BanksController extends _$BanksController {
Future<bool> assignBudget(BankAccount account, String? budgetId) =>
_updateAccount(account.id, BankAccountRequest((builder) => builder.budgetId = budgetId));

/// Two years of history, queued. There is no completion signal, so the user pulls to refresh.
/// Two years of history, queued. The silent push at the end of the run is what refreshes the
/// lists, so there is nothing to wait on here.
Future<bool> syncHistory(String memberId) => _write(() => _api.syncBank(id: memberId).orApiError());

BanksApi get _api => ref.read(apiProvider).getBanksApi();
Expand Down
6 changes: 2 additions & 4 deletions mobile/lib/banks/banks_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,12 +93,10 @@ class _Member extends ConsumerWidget {

if (!queued || !context.mounted) return;

// The job answers nothing when it finishes, so say so rather than implying a wait.
// The request only queues the job; the silent push at the end of the run is what refreshes.
ScaffoldMessenger.of(context)
..clearSnackBars()
..showSnackBar(
const SnackBar(content: Text('Syncing history. Pull to refresh to see what has landed.')),
);
..showSnackBar(const SnackBar(content: Text('Syncing history. The lists update when it finishes.')));
}
}

Expand Down
72 changes: 72 additions & 0 deletions mobile/lib/push/push_channel.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'push_channel.g.dart';

/// What the device has to say about push.
sealed class PushEvent {
const PushEvent();
}

/// iOS issued a device token. It arrives after [PushChannel.register] and again whenever iOS
/// reissues one, so it is an event rather than the return of the call that asked for it.
class PushToken extends PushEvent {
const PushToken(this.token);

final String token;
}

/// A silent push: a sync finished and whatever is on screen is now behind.
class PushRefresh extends PushEvent {
const PushRefresh();
}

/// The user tapped a notification.
class PushOpened extends PushEvent {
const PushOpened();
}

/// Permission, registration, and the events that follow. An interface so tests do not need a
/// platform channel.
abstract interface class PushChannel {
Stream<PushEvent> get events;

/// Asks for permission and registers with APNs either way - a silent push needs no permission.
/// Answers whether the user allowed alerts.
Future<bool> register();
}

class PlatformPushChannel implements PushChannel {
PlatformPushChannel([MethodChannel? channel])
: _channel = channel ?? const MethodChannel('spendable/push') {
_channel.setMethodCallHandler(_receive);
}

final MethodChannel _channel;
final _events = StreamController<PushEvent>.broadcast();

@override
Stream<PushEvent> get events => _events.stream;

@override
Future<bool> register() async => await _channel.invokeMethod<bool>('register') ?? false;

Future<void> _receive(MethodCall call) async {
switch (call.method) {
case 'token':
_events.add(PushToken(call.arguments as String));
case 'refresh':
_events.add(const PushRefresh());
case 'opened':
_events.add(const PushOpened());
}
}
}

@Riverpod(keepAlive: true)
PushChannel pushChannel(Ref ref) => PlatformPushChannel();

@Riverpod(keepAlive: true)
Stream<PushEvent> pushEvents(Ref ref) => ref.watch(pushChannelProvider).events;
Loading
Loading