WebView-App: Payment Flows, State Sync und Plattform-Hacks (Teil 2)
Zweites Projekt, gleiche Probleme. Payment-Flows, die die App verlassen, API-Interception für State-Sync und 40 TWINT-URL-Schemes.
WebView-App: Payment Flows, State Sync und Plattform-Hacks (Teil 2)
TL;DR: Teil 1 hat gezeigt, wie komplex es wird, Websites in Apps einzubetten — am Beispiel eines Schmuck-E-Commerce-Projekts. In Teil 2 zeigt ein zweites Projekt (Schweizer Elektronikhändler, Custom Backend, andere Payment-Provider), dass dieselben strukturellen Probleme wiederkehren. Plus zusätzliche Schichten: Payment-Flows, die App-Grenzen überschreiten, native State-Synchronisation durch API-Interception und Plattform-Permissions, die auf iOS und Android komplett unterschiedlich funktionieren.
Was ist WebView?
WebView ist eine Systemkomponente, die es mobilen Apps ermöglicht, Web-Inhalte direkt in der Anwendung darzustellen — im Grunde ein Browser ohne Adressleiste. Eine WebView-App bettet bestehende Websites in einen nativen Wrapper ein, statt sie nativ nachzubauen. Dieser scheinbar einfache Ansatz erzeugt in der Praxis erhebliche Komplexität.
Anderes Projekt, gleiche Probleme
Cross-Plattform App-Entwicklung — einschließlich WebView-basierter Hybrid-Apps — wächst laut Precedence Research jährlich um 17,3% bis 2034. Mehr Hybrid-Apps bedeuten mehr Teams, die auf dieselben WebView-Probleme stoßen.
Nach Teil 1 könnte man argumentieren: “Das war ein Shopify-Projekt mit Shopify-Eigenheiten. Custom Backends wären einfacher.”
Die Realität widerspricht dem. Das zweite Projekt: eine Flutter-App für einen Schweizer Elektronikhändler. Kein Shopify — stattdessen ein proprietäres Backend mit REST-APIs, Custom Authentication und Payment-Integration via Datatrans. Gleiche strukturelle Probleme.
Architektur spiegelt den Hybrid-Ansatz: Native Screens für Auth, Home, Suche und Merklisten. WebViews für Produktseiten, Warenkorb, Checkout und Kontoeinstellungen. Gleiches Plugin-System. Gleiche JavaScript-Bridge (window.mobileApp). Gleiche shouldOverrideUrlLoading-Logik mit plattformspezifischen Workarounds.
Aber dieses Projekt hat etwas, das die Shopify-Version nicht hatte: einen Payment-Flow, der die WebView verlässt. Das macht einen echten Unterschied.
In Zahlen: 648 Zeilen für den WebView-Container, 223 für den Payment-Browser, 436 für die Checkout-Seite, 10 Plugins. Für “einfach die Website einbetten.”
Payment Flows: Wenn WebView nicht reicht
Im Shopify-Projekt lief der gesamte Checkout innerhalb der WebView. Hier nicht. Payment-Flows sind eine eigene Komplexitätsschicht.
Das Problem: Drei Welten statt zwei
Der Checkout-Ablauf:
- User sieht den Warenkorb (WebView #1)
- Navigiert zum Checkout (WebView #1 wechselt zur Checkout-URL)
- Wählt Zahlungsmethode und klickt “Bezahlen”
- Payment-Provider (Datatrans) öffnet seine eigene Oberfläche
- Je nach Zahlungsmethode startet eine externe App (TWINT, Google Pay)
- Nach Zahlung kehrt der User zur App zurück — auf die Bestätigungsseite
Das sind nicht zwei Welten (nativ + web), sondern drei: nativ + WebView + externe Payment-Apps. Jeder Übergang ist ein potenzieller Fehlerquell.
InAppBrowser: Separater Browser für Payments
Die Lösung nutzt InAppBrowser — nicht InAppWebView. Der Unterschied ist wesentlich: Ein InAppBrowser ist ein eigenständiges Browserfenster mit eigener Toolbar, das über der App schwebt. Ein InAppWebView ist ein Widget im Flutter-Widget-Tree.
Warum separat? Weil Payment-Provider wie Datatrans URLs aufrufen, die Navigation außerhalb der normalen WebView-Grenzen erfordern — zu TWINT, Kreditkarten-Verifizierungsseiten, externen Apps. Eine eingebettete WebView kann das nicht zuverlässig.
Future<void> launchPaymentBrowser(Uri paymentUri) async {
final paymentBrowser = PaymentBrowser(
ref,
checkoutWebviewController: webviewController,
paymentSuccessCallback: () async {
context.router.popUntilRoot();
await context.router.navigate(const HomeTab());
},
);
await paymentBrowser.openUrlRequest(
urlRequest: URLRequest(url: WebUri.uri(paymentUri)),
settings: InAppBrowserClassSettings(
browserSettings: InAppBrowserSettings(
hideUrlBar: true,
hideToolbarBottom: true,
hideDefaultMenuItems: true,
closeButtonCaption: 'Zurück zum Shop',
),
),
);
} Sofort zeigt sich Plattform-Divergenz: iOS und Android brauchen unterschiedliche Close-Button-Implementierungen. iOS bietet ein natives closeButtonCaption. Android braucht Toolbar-Menu-Item-Injection:
if (Platform.isAndroid) {
browser.addMenuItem(
InAppBrowserMenuItem(
id: 1,
title: 'Zurück zum Shop',
showAsAction: true,
order: 0,
onClick: () {
browser.close();
browser.onExit();
},
),
);
} TWINT und intent:// — Wenn URLs keine URLs mehr sind
TWINT ist die Schweizer Mobile-Payment-Lösung und nutzt Custom-URL-Schemes: twint://, twint-issuer1:// bis twint-issuer39://, twint-extended://. Vierzig verschiedene URL-Schemes für einen Payment-Provider.
Der Payment-Browser muss diese Schemes erkennen und ans Betriebssystem weiterleiten, um die TWINT-App zu aktivieren:
bool isCustomAppScheme(String url) {
if (url.isEmpty) return false;
final uri = Uri.parse(url);
final scheme = uri.scheme.toLowerCase();
if (scheme.startsWith('twint')) return true;
if (url.contains('://applinks/')) return true;
return false;
} Android unterstützt zusätzlich intent://-URLs. Diese speziellen Android-URIs öffnen Apps direkt — mit Play-Store-Fallback, falls die App nicht installiert ist:
if (Platform.isAndroid && url.startsWith('intent://')) {
try {
await AndroidIntent.parseAndLaunch(uri.toString());
return NavigationActionPolicy.CANCEL;
} catch (e) {
log('Error launching Android Intent: $e');
return NavigationActionPolicy.ALLOW;
}
} iOS kennt kein intent:// — Custom-URL-Schemes werden dort direkt über launchUrl geöffnet. Wieder zwei separate Code-Pfade für zwei Plattformen.
Fehlererkennung und Bestätigungsseiten
Der Payment-Browser muss feststellen, ob die Zahlung erfolgreich war. Kein Callback, kein Event — nur die URL. Der Browser speichert jede besuchte URL in einer Queue und prüft bei jedem Seitenwechsel:
class PaymentBrowser extends InAppBrowser {
final visitedUrls = Queue<Uri>();
void onLoadStart(WebUri? url) {
if (url.toString().contains('datatrans=error')) {
close();
return;
}
if (url != null) visitedUrls.add(url);
}
Future<void> onExit() async {
await ref.read(cartControllerProvider.notifier).refresh();
if (lastVisitedUrl != null) {
final isConfirmation =
await isCheckoutConfirmationUrl(lastVisitedUrl!);
if (isConfirmation) {
await paymentSuccessCallback();
} else {
await checkoutWebviewController?.reload();
}
}
}
} Wenn der User den Payment-Browser schließt — per Close-Button oder Systemgeste — bestimmt die App den Zahlungserfolg anhand der letzten besuchten URL. Keine API-Abfrage, kein Server-Event — reines URL-Pattern-Matching gegen Remote-Config-Werte.
Bestätigungs-URLs kommen nicht aus hartcodierten Werten, sondern aus Firebase Remote Config. Payment-URLs ändern sich ohne App-Update.
Die Checkout State Machine
Der Checkout hat eine eigene State Machine. Ein cart_mutation-URL-Parameter signalisiert, ob sich der Warenkorb seit dem letzten Checkout geändert hat:
bool shouldInitCart(WidgetRef ref, AsyncValue<CartData> cartState) {
final cartWebviewState = ref.watch(cartWebviewControllerProvider);
final currentCartHash = cartState.value.hashCode;
final lastCheckoutCartHash = cartWebviewState.lastCheckoutCartHash;
return lastCheckoutCartHash == null ||
currentCartHash != lastCheckoutCartHash;
}
final checkoutUrl =
'${baseUrl}/checkout?cart_mutation=${shouldInitCart ? 1 : 0}'; Warum das wichtig ist: Die Checkout-WebView bleibt zwischen Tab-Wechseln bestehen. Wenn User zum Warenkorb navigieren, ein Produkt hinzufügen und dann zum Checkout zurückkehren, muss die App entscheiden, ob der Checkout neu initialisiert werden muss oder der bestehende State gültig bleibt.
Eine URL-Blacklist aus Remote Config regelt das:
final blacklistHit = shopConfig
.checkoutBlacklistRules
.matchUri(uri);
if (blacklistHit) {
context.router.push(StandaloneWebViewRoute(url: uri.toString()));
return NavigationActionPolicy.CANCEL;
} Das erlaubt zur Laufzeit — ohne App-Update — bestimmte URLs aus dem Checkout-Flow zu entfernen. Wenn ein Payment-Provider eine neue Redirect-Seite einführt, die den Checkout bricht, fügt das Backend-Team die URL zur Blacklist hinzu, und die App öffnet sie in einer separaten WebView.
Native State-Synchronisation: Die unsichtbare Komplexität
Im Shopify-Projekt gab es eine JavaScript-Bridge, über die die Website die App aktiv informierte (“Hey, Warenkorb hat sich geändert!”). Das setzt Kooperation der Website voraus — webseitiger Code muss die Bridge aufrufen.
Dieses Projekt implementiert einen zusätzlichen, passiven Ansatz: Die App beobachtet WebView-API-Aufrufe.
onLoadResource: Der stille Beobachter
Wann immer die WebView eine Ressource lädt — Bilder, Scripts, API-Calls — feuert der onLoadResource-Callback. Die App nutzt das, um bestimmte API-Endpoints zu erkennen und nativen State zu aktualisieren:
onLoadResource: (controller, resource) {
handlePricePreferenceChange(resource, ref);
handleWatchlistChanges(resource, ref);
handleCompareListChange(resource, ref);
}, Konkret implementiert:
void handlePricePreferenceChange(LoadedResource resource, WidgetRef ref) {
final isPricesEndpoint = resource.url
.toString()
.endsWith('api/accounts/settings/prices');
if (isPricesEndpoint) {
ref.read(sessionControllerProvider.notifier)
.refreshSessionState();
}
}
void handleWatchlistChanges(LoadedResource resource, WidgetRef ref) {
final isWatchlistOverview = resource.url
.toString()
.endsWith('/api/watchlists');
if (isWatchlistOverview) {
ref.invalidate(watchlistOverviewControllerProvider);
}
final addItemRegex = RegExp(r'/api/watchlists/(.+)/items$');
final deleteItemRegex =
RegExp(r'/api/watchlists/(.+)/items/(.+)$');
final isItemChange = resource.url.toString()
.contains(addItemRegex) ||
resource.url.toString().contains(deleteItemRegex);
if (isItemChange) {
final watchlistId = resource.url
.toString()
.split('/api/watchlists/')[1]
.split('/')[0];
ref.read(watchlistControllerFamily(watchlistId)
.notifier).refresh();
ref.invalidate(watchlistOverviewControllerProvider);
}
} Gleichzeitig elegant und fragil. Elegant, weil keine Änderung an der Web-Codebasis nötig ist — die App beobachtet einfach das WebView-Verhalten. Fragil, weil API-Endpoint-Änderungen die Pattern-Erkennung brechen.
onLoadResource feuert für alle Ressourcen — Bilder, CSS, JavaScript, Fonts, API-Calls. Die App filtert nach URL-Patterns und reagiert nur auf relevante Endpoints. Das funktioniert, solange die Endpoint-Struktur stabil bleibt.
Plattform-Permissions: Kamera, 2FA und User-Agent-Täuschung
HeyLight Pay: Wenn der Payment-Provider Kamera braucht
HeyLight — ein Buy-Now-Pay-Later Payment-Provider — braucht Kamerazugriff für Identitätsverifizierung. In normalen Browsern unproblematisch. In einer WebView in einer App? Extrem aufwändig.
Problem 1: HeyLight erkennt WebView. HeyLight macht Browser-Detection und blockiert alles, was nicht Safari oder Chrome ist. Standard Flutter-WebView User-Agent-Identifikation wird abgelehnt.
Die Lösung — ähnlich wie beim Shopify-Projekt: User-Agent-Spoofing. Aber nur iOS, weil HeyLight explizit Safari prüft:
userAgent: Platform.isIOS
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_7_1 like Mac OS X) '
'AppleWebKit/605.1.15 (KHTML, like Gecko) '
'Version/26.0 Mobile/15E148 Safari/604.1'
: null, Problem 2: iOS doppelt Kamera-Permissions anfordern crasht. Wenn die WebView Kamera-Permissions anfordert, zeigt iOS automatisch den Systemdialog. Wenn die App zusätzlich Permission.camera.request() aufruft, gibt’s Probleme — der Dialog erscheint doppelt oder die Permission wird still verweigert.
onPermissionRequest: (controller, request) async {
final resources = request.resources;
if (resources.contains(PermissionResourceType.CAMERA)) {
final isGranted = await Permission.camera.isGranted;
if (Platform.isIOS) {
return PermissionResponse(
resources: resources,
action: isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,
);
}
var status = await Permission.camera.request();
if (!status.isGranted) {
await showDialog(
context: context,
builder: (_) => const CameraPermissionDeniedDialog(),
);
}
status = await Permission.camera.status;
return PermissionResponse(
resources: resources,
action: status.isGranted
? PermissionResponseAction.GRANT
: PermissionResponseAction.DENY,
);
}
return PermissionResponse(resources: resources);
}, Drei verschiedene Verhaltensweisen: iOS zeigt den Dialog selbst, Android braucht manuelles Triggering, und dauerhaft verweigerte Permissions brauchen User-Guidance zu den Geräteeinstellungen. Alles für einen Payment-Provider, der Kamerazugriff braucht.
TOTP/2FA: Wenn WebView eine andere App öffnen muss
Die Web-Authentifizierung unterstützt Zwei-Faktor-Authentifizierung via TOTP. Beim Einrichten von 2FA wird ein otpauth://-Link für eine Authenticator-App generiert.
Normale Browser öffnen die Authenticator-App automatisch. WebViews? Der Link wird als Navigation behandelt — und scheitert.
if (url.startsWith('otpauth:')) {
await handleTotpUrl(url);
return NavigationActionPolicy.CANCEL;
} Future<void> handleTotpUrl(String totpUrl) async {
try {
final uri = Uri.parse(totpUrl);
final canLaunch = await canLaunchUrl(uri);
if (canLaunch) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
showWarning(
title: 'Keine Authenticator-App gefunden',
subtitle: 'Bitte installiere eine Authenticator-App...',
);
}
} catch (e) {
showWarning(
title: 'Fehler',
subtitle: 'Der QR-Code konnte nicht verarbeitet werden.',
);
}
} CSS-Injection und andere kreative Lösungen
Pull-to-Refresh: 101vh
Pull-to-Refresh in WebView hat einen Bug: Wenn der Seiteninhalt nicht höher als das Viewport ist, funktioniert die Geste nicht. Die Lösung? CSS-Injection, die den Inhalt künstlich um 1% vergrößert:
class WebviewPullToRefreshEnablerPlugin implements IWebViewPlugin {
List<UserScript>? get userScripts => [
UserScript(
source: """
(function() {
var style = document.createElement('style');
style.textContent = 'main { min-height: 101vh; }';
document.head.appendChild(style);
})();
""",
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
),
];
} 101% Viewport-Höhe. Weil 100% der WebView nicht signalisiert, dass gescrollt werden kann — und ohne Scroll-Fähigkeit feuert Pull-to-Refresh nicht.
Bot-Detection: Ein Cookie für die Entwicklung
Analytics-Services wie Tealium nutzen Bot-Detection. In der Entwicklung — wo Entwickler Seiten hundertfach täglich laden — wird die WebView als Bot erkannt und blockiert.
if (kDebugMode)
Cookie(name: 'teal_bdwl', value: '1'), Ein einziges Cookie. Ohne es wird Entwicklung unmöglich.
Pattern-Erkennung
Zwei Projekte. Verschiedene Branchen (Schmuck vs. Elektronik). Verschiedene Backends (Shopify vs. Custom). Verschiedene Payment-Provider (Shop Pay vs. Datatrans/TWINT). Trotzdem dieselben Patterns:
| Pattern | Projekt 1 (Schmuck/Shopify) | Projekt 2 (Elektronik/Custom) |
|---|---|---|
| User-Agent-Spoofing | Shopify blockiert WebView-UA | HeyLight blockiert Non-Safari-UA |
| iOS vs. Android Navigation | NavigationType.BACK_FORWARD vs. isRedirect | onDidReceiveServerRedirectForProvisionalNavigation vs. isRedirect |
| WebView Destroy & Recreate | ValueKey mit Auth-Token | rebuildIndex mit Cookie-Änderungen |
| Künstliche Delays | 700ms für Back-Navigation-Reload | 300ms für Share-Dialog-Timing |
| CSS/JS-Injection | Consent-Daten, Höhenmessung | Pull-to-Refresh 101vh, Padding-Fix |
| Remote Config | Whitelisted Domains | Checkout-Blacklist, Payment-URLs |
Das sind keine zufälligen Überschneidungen. Das sind strukturelle Konsequenzen des WebView-Ansatzes.
Fazit
Die Erkenntnis aus beiden Projekten: WebView-Komplexität ist nicht projektspezifisch — sie ist strukturell.
Backend-Herkunft — Shopify oder Custom — spielt keine Rolle. Payment-Provider — Shop Pay oder Datatrans — spielt keine Rolle. Branche — Schmuck oder Elektronik — spielt keine Rolle. Sobald man Websites in native Apps einbettet, tauchen dieselben Probleme auf.
Das heißt nicht, dass WebViews die falsche Wahl sind. Es heißt: Für die Komplexität planen. Nicht “machen wir später”, sondern als permanenten Bestandteil der Architekturplanung.
Die Frage für Entscheider bleibt dieselbe wie in Teil 1: Nicht ob, sondern wo. Welche Screens nativ, welche WebView, wie kommunizieren? Wer diese Frage mit dem Wissen aus zwei echten Projekten beantwortet, trifft bessere Entscheidungen.
Häufige Fragen
Können Payment-Flows wie TWINT oder Google Pay direkt in der WebView laufen?
Unzuverlässig, bestenfalls. Payment-Provider wie TWINT nutzen Custom-URL-Schemes (twint://, twint-issuer1:// bis twint-issuer39://), die sich nicht direkt aus der WebView öffnen lassen. Lösung: Separater InAppBrowser, der Schemes ans Betriebssystem weiterleitet.
Was unterscheidet InAppWebView von InAppBrowser in Flutter?
InAppWebView wird in Flutters Widget-Tree eingebettet — vergleichbar mit HTML <iframe>. InAppBrowser öffnet ein eigenständiges Browserfenster mit eigener Toolbar, das über der App schwebt.
Warum ist User-Agent-Spoofing in WebView-Apps nötig?
Viele Server und Services erkennen Standard-WebView User-Agents und verweigern den Zugriff. In der Praxis müssen User-Agents pro Plattform hartcodiert werden.
Wie synchronisiert man nativen App-State mit der WebView?
Zwei Ansätze: aktiv (JavaScript-Bridge, Website benachrichtigt App) und passiv (App beobachtet per onLoadResource WebView-API-Calls). Beides hat Trade-offs.
Sind WebView-Probleme Framework-spezifisch oder plattformübergreifend?
Die Probleme sind plattformübergreifend und strukturell. Ob Flutter, React Native oder native Entwicklung — die grundlegenden Herausforderungen treten in jedem Framework auf, das WebViews einsetzt.