Textpad for Flutter
What it is
This is Textpad, HashBin's example text editor, rebuilt as an iOS and Android app with Flutter. It does the same things as the web version, and this page explains the parts that work differently on a phone. There is also an Expo version.
- Open: a link such as
org.hashbin.textpad.flutter://open?256t=<256t string>opens Textpad with that text. Users can also paste a 256t string, a 256t.us link or a web Textpad link into the Open field. - Edit and save: Save publishes the text to the user's HashBin account. Copy link then copies a link that opens the text in any browser.
- Pay with the user's account: the user approves Textpad once on hashbin.org, inside the app, and stays connected.
- Explain problems: a malformed 256t string, one with nothing stored under it, or content that isn't text each gets its own message.
The project is in the HashBin repository at
examples/textpad-flutter.
The code is in three files under lib/:
textpad.dart | 256t strings, links and text decoding. Plain Dart, tested in test/textpad_test.dart. |
hashbin.dart | A small HashBin OAuth client: sign-in, token storage and renewal, and API calls. Copy it into your own app. Tested in test/hashbin_test.dart. |
main.dart | The screen, and what happens when the user taps something. |
It uses these packages: http, crypto (for PKCE),
flutter_web_auth_2 (the sign-in browser), flutter_secure_storage
(tokens), app_links (incoming links) and url_launcher (opening
web pages).
Try it
Install the app on a phone or simulator first. These links then open in it; short texts need no account, because they are stored inside the 256t string itself.
| Link | What you'll see |
|---|---|
| A short text | "Hello from Textpad! Edit me, then click Save." Edit it and tap Save to try saving. |
| A malformed 256t string | An error saying the string isn't a valid 256t string. |
| Content that isn't text | An error saying the content isn't text. |
In the simulator you can open a link from a terminal:
xcrun simctl openurl booted "org.hashbin.textpad.flutter://open?256t=…" on iOS, or
adb shell am start -a android.intent.action.VIEW -d "org.hashbin.textpad.flutter://open?256t=…"
on Android.
Signing in from an app
A phone app signs in to HashBin with the same OAuth 2.0 and PKCE flow as a web page. Four things are different:
-
The approval page opens in an in-app browser. On iOS this is an
ASWebAuthenticationSession, on Android a Custom Tab. The user sees the real hashbin.org address, and it shares the browser's sign-in, so they often don't need to sign in again. iOS first asks "Textpad Wants to Use hashbin.org to Sign In"; that prompt comes from the system, not from HashBin. -
The redirect URI uses the app's own URL scheme:
org.hashbin.textpad.flutter://oauth. HashBin accepts redirect URIs like this when the scheme is named after a domain, written backwards (RFC 8252). A plain name such astextpad://is refused, because any app could claim it. -
Tokens are kept in secure storage, the iOS Keychain or the Android
Keystore, through
flutter_secure_storage. A web page has to uselocalStorage. - Nothing has to survive a page change. The app keeps running while the browser is open, so unlike the web version it doesn't save the user's text before signing in.
Everything else matches the web version. Textpad asks for content:write and
balance:read. Each save is charged to the user's balance and kept for the
default retention period set on their account page, and texts
of 64 bytes or less are free. Disconnect revokes the app's access on the
server, and users can also revoke Textpad from their account page.
Messages and what causes them
| Situation | Textpad shows |
|---|---|
The string has characters outside A–Z a–z 0–9 - _, or its length doesn't match the length it encodes (for example, a link cut off when copied) | "… isn't a valid 256t string. Check that the link was copied completely." |
The link ends in 256t= with nothing after it | "This link has "256t=" but no 256t string after it." |
| Something pasted into Open is a link with no 256t string in it | "That link doesn't contain a 256t string." |
| 256t.us answers 404: never saved, expired, or deleted | "Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved." |
| 256t.us answers 451: the content is under dispute | "This text is unavailable while a dispute about it is reviewed." |
| 256t.us answers 429: the text has used up its download allowance | "HashBin is limiting how often this text can be downloaded. Try again later." |
| The content isn't valid UTF-8, or contains NUL characters (binary data) | "This 256t string points to content that isn't text, so Textpad can't open it." |
| The network request fails | "Couldn't reach HashBin to load this text…" |
| The user taps Deny on the HashBin approval page | "You didn't approve Textpad on HashBin, so nothing was saved." Their edits are kept. |
| The user closes the approval page | Nothing; the text stays as it was, unsaved. |
| The balance is too low to save | HashBin's explanation, with an Add funds button. |
| The connection was revoked or has expired | "Your HashBin connection has ended. Tap Save to connect again." |
The code, step by step
The excerpts below are copied exactly from the app; the full source is at the end of this page.
1. Configuration
main.dart starts by naming the app (its client ID), where HashBin is, and the
redirect URI. --dart-define values let a developer point the app at a local
HashBin server without editing the code; they are fixed when the app is built.
// The HashBin service, and the domain that serves published content.
// Development only: --dart-define=HASHBIN_URL=... points the app at a local
// HashBin server (npm run dev:local), which serves content itself.
const hashbinUrl = String.fromEnvironment('HASHBIN_URL', defaultValue: 'https://hashbin.org');
const contentUrl = String.fromEnvironment('HASHBIN_URL', defaultValue: 'https://256t.us');
// The client ID from registering Textpad at https://hashbin.org/developers,
// or --dart-define=TEXTPAD_CLIENT_ID=... for an app registered on a local
// server.
const clientId = String.fromEnvironment(
'TEXTPAD_CLIENT_ID',
defaultValue: 'app_1a74e62b-d752-4a3c-81af-5c850af18c5b',
);
// HashBin sends the user back to this link after they approve Textpad. Its
// scheme is the app's own, so the system hands the link to this app. It
// must match a registered redirect URI exactly.
const redirectUri = 'org.hashbin.textpad.flutter://oauth';
// What Textpad asks the user to allow: publishing (billed to their
// balance) and reading their balance so it can be shown.
const scopes = ['content:write', 'balance:read'];
final account = HashBinAccount(
clientId: clientId,
redirectUri: redirectUri,
baseUrl: hashbinUrl,
scopes: scopes,
);
2. The app's URL scheme
On iOS, ios/Runner/Info.plist claims the scheme with a
CFBundleURLTypes entry (see the full source). On
Android, two activities share it. Links whose host is open go to the app:
<!-- Links to a text, org.hashbin.textpad.flutter://open?256t=...,
open Textpad. The app_links package passes them to the app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.hashbin.textpad.flutter" android:host="open"/>
</intent-filter>
<!-- The app_links package handles incoming links, not Flutter's router. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false"/>
and links whose host is oauth go to flutter_web_auth_2, which is waiting for the end of a sign-in:
<!-- Catches org.hashbin.textpad.flutter://oauth?code=... at the end of
signing in to HashBin and hands it to flutter_web_auth_2. -->
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true"
android:taskAffinity="">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.hashbin.textpad.flutter" android:host="oauth"/>
</intent-filter>
</activity>
Both platforms also turn off Flutter's own link handling
(FlutterDeepLinkingEnabled and flutter_deeplinking_enabled),
because app_links handles the links instead.
3. Finding the 256t string in a link
Textpad accepts its own deep links, and also the links people are likely to have: a web Textpad link, a raw 256t.us link, or just the string. It ignores links that hold no 256t string.
/// Finds the 256t string in any of these:
/// - Textpad's own deep link: `org.hashbin.textpad.flutter://open?256t=<cid>`
/// - a web Textpad link: `https://hashbin.org/docs/textpad/app#256t=<cid>`
/// - a raw content link: `https://256t.us/<cid>`
/// - the bare 256t string: `<cid>`
/// Returns null for a link that holds no 256t string.
String? cidFromText(String text) {
final trimmed = text.trim();
final param = RegExp(r'[#?&]256t=([^&#]*)').firstMatch(trimmed);
if (param != null) {
try {
return Uri.decodeComponent(param[1]!);
} on ArgumentError {
return param[1]; // A stray "%" can't be decoded; isValid256t rejects it.
}
}
final contentLink = RegExp(r'^https://256t\.us/([^/?#]*)').firstMatch(trimmed);
if (contentLink != null) {
return contentLink[1];
}
return trimmed.contains('://') ? null : trimmed;
}
4. Checking a 256t string
A 256t string has two parts, both in base64url. The first 8 characters hold the content's length in bytes. The rest is the content itself when it is 64 bytes or less, or else its SHA-512 hash, which is always 86 characters. Checking that the two parts agree catches typos and truncated links before any network request is made:
/// A 256t string is base64url text in two parts:
/// - 8 characters holding the content's length in bytes
/// (a 6-byte big-endian number);
/// - then the content itself if it is 64 bytes or less, or else the
/// content's SHA-512 hash (always 86 characters).
/// Checking this up front gives a clear message for a mistyped or cut-off
/// link instead of a confusing "not found".
int contentLength(String cid) =>
base64UrlToBytes(cid.substring(0, 8)).fold(0, (total, byte) => total * 256 + byte);
bool isValid256t(String cid) {
if (!RegExp(r'^[A-Za-z0-9_-]{8,94}$').hasMatch(cid)) {
return false;
}
final length = contentLength(cid);
final expectedRest = length <= 64 ? (length * 4 / 3).ceil() : 86;
return cid.length - 8 == expectedRest;
}
5. Turning bytes into text
HashBin stores bytes, not text. Dart's utf8.decode refuses anything that isn't
valid UTF-8, but it quietly drops a leading byte order mark, so Textpad puts it back.
Otherwise saving an unchanged file would store different bytes under a new 256t string.
/// Content on HashBin is just bytes. Textpad only opens bytes that are valid
/// UTF-8 text: utf8.decode throws a FormatException on anything else
/// instead of inserting replacement characters.
String decodeText(List<int> bytes) {
// utf8.decode drops a leading byte order mark. Put it back, so saving
// unchanged text gives back exactly the same bytes and 256t string.
final hasBom = bytes.length >= 3 && bytes[0] == 0xef && bytes[1] == 0xbb && bytes[2] == 0xbf;
final text = (hasBom ? '\uFEFF' : '') + utf8.decode(hasBom ? bytes.sublist(3) : bytes);
// Valid UTF-8 can still be binary data; a NUL character is a reliable
// sign of that, and real text practically never contains one.
if (text.contains('\u0000')) {
throw const FormatException('Content contains NUL characters');
}
return text;
}
6. Opening a text
Opening checks the string, then either decodes a short text straight from it or downloads a
longer one from https://256t.us/<string>. Downloading needs no account.
Each failure gets its own message, and the editor is emptied first, so nothing on screen
can be mistaken for the content of a broken link.
Future<void> openCid(String cid) async {
// Whatever happens below, don't leave the previous text on screen as if
// it belonged to this link.
setDocument(null, '');
clearMessage();
if (cid.isEmpty) {
showMessage('This link has "256t=" but no 256t string after it.', error: true);
return;
}
if (!isValid256t(cid)) {
final shown = cid.length > 100 ? '${cid.substring(0, 100)}…' : cid;
showMessage('"$shown" isn\'t a valid 256t string. Check that the link was copied completely.', error: true);
return;
}
// Short texts are inside the 256t string itself.
final inline = inlineBytes(cid);
if (inline != null) {
showText(cid, inline);
return;
}
setBusy(true);
showMessage('Loading…');
try {
final http.Response response;
try {
response = await http.get(Uri.parse('$contentUrl/$cid'));
} catch (_) {
showMessage("Couldn't reach HashBin to load this text. Check your connection and try again.", error: true);
return;
}
switch (response.statusCode) {
case 200:
showText(cid, response.bodyBytes);
case 404:
showMessage('Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved.', error: true);
case 451:
showMessage('This text is unavailable while a dispute about it is reviewed.', error: true);
// Each stored text has a download allowance; when it runs out, 256t.us answers 429.
case 429:
showMessage('HashBin is limiting how often this text can be downloaded. Try again later.', error: true);
default:
showMessage("HashBin couldn't return this text (HTTP ${response.statusCode}). Try again later.", error: true);
}
} finally {
setBusy(false);
}
}
7. Signing in
connect() in hashbin.dart builds the authorization link with a PKCE
challenge and opens it with FlutterWebAuth2.authenticate. When HashBin
redirects to org.hashbin.textpad.flutter://oauth?code=…, the browser closes and
hands that link back. The app checks the state, then trades the code for
tokens.
/// Opens HashBin's approval page in an in-app browser and waits for the
/// user to come back.
Future<ConnectResult> connect() async {
// PKCE: a random secret (the verifier) stays in the app, and only its
// SHA-256 hash (the challenge) goes into the sign-in link. Whoever
// exchanges the code for tokens must know the verifier, so a code
// intercepted on the way back to the app is useless to anyone else.
final random = Random.secure();
String randomString(int bytes) =>
bytesToBase64Url(List.generate(bytes, (_) => random.nextInt(256)));
final verifier = randomString(32);
final challenge = bytesToBase64Url(sha256.convert(utf8.encode(verifier)).bytes);
// "state" ties the reply to this request.
final state = randomString(16);
final authorizeUrl = Uri.parse('$baseUrl/oauth/authorize').replace(queryParameters: {
'client_id': clientId,
'redirect_uri': redirectUri,
'response_type': 'code',
'scope': scopes.join(' '),
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
});
// On iOS this is an ASWebAuthenticationSession, on Android a Custom
// Tab. It closes by itself when HashBin redirects to a link with the
// redirect URI's scheme, and hands that link back here.
final String result;
try {
result = await FlutterWebAuth2.authenticate(
url: authorizeUrl.toString(),
callbackUrlScheme: Uri.parse(redirectUri).scheme,
);
} on PlatformException catch (error) {
if (error.code == 'CANCELED') return ConnectResult.cancelled;
rethrow;
}
final params = Uri.parse(result).queryParameters;
if (params['state'] != state) {
throw Exception("The reply from HashBin doesn't match this sign-in.");
}
if (params['error'] == 'access_denied') {
return ConnectResult.denied;
}
final code = params['code'];
if (params['error'] != null || code == null) {
throw Exception(params['error'] ?? "HashBin didn't send an authorization code.");
}
// Trade the one-time code (plus the verifier) for tokens.
final response = await _http.post(
Uri.parse('$baseUrl/oauth/token'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'grant_type': 'authorization_code',
'client_id': clientId,
'redirect_uri': redirectUri,
'code': code,
'code_verifier': verifier,
}),
);
final body = _jsonObject(response);
if (response.statusCode != 200) {
throw Exception(body['message'] ?? body['error'] ?? 'HTTP ${response.statusCode}');
}
await _saveTokens(body);
return ConnectResult.connected;
}
8. Renewing access
Access tokens last an hour. Every API call goes through account.send(), which
renews an expired token first. Refresh tokens work only once, so calls that need a new
token at the same moment share a single renewal. A refused refresh token, or a 401 from
the API, means the user has to approve Textpad again.
/// Access tokens last an hour. A refresh token gets a new one, and is
/// itself replaced: each refresh token works only once. Calls that need a
/// new token at the same moment share one refresh, because a second
/// refresh with the same (by then used) token would fail.
Future<void> _refreshAccessToken() {
return _refreshing ??= _renewTokens().whenComplete(() => _refreshing = null);
}
Future<void> _renewTokens() async {
final response = await _http.post(
Uri.parse('$baseUrl/oauth/token'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'grant_type': 'refresh_token', 'refresh_token': _refreshToken}),
);
final body = _jsonObject(response);
if (body['error'] == 'invalid_grant') {
// Expired or revoked: the user has to approve the app again.
await _clearTokens();
throw NotConnectedException();
}
if (response.statusCode != 200) {
throw Exception(body['message'] ?? body['error'] ?? 'HTTP ${response.statusCode}');
}
await _saveTokens(body);
}
/// Sends a request to a HashBin API path as the signed-in user.
Future<http.Response> send(String method, String path,
{Map<String, String> headers = const {}, String? body}) async {
if (!isConnected) {
throw NotConnectedException();
}
if (DateTime.now().isAfter(_expiresAt.subtract(_expiryMargin))) {
await _refreshAccessToken();
if (!isConnected) {
throw NotConnectedException(); // Disconnected while renewing.
}
}
final request = http.Request(method, Uri.parse('$baseUrl$path'))
..headers.addAll(headers)
..headers['authorization'] = 'Bearer $_accessToken';
if (body != null) {
request.bodyBytes = utf8.encode(body);
}
final response = await http.Response.fromStream(await _http.send(request));
if (response.statusCode == 401) {
await _clearTokens();
throw NotConnectedException();
}
return response;
}
9. Saving text
Save connects first if needed, then sends one POST /api/content whose body is
the text. The response holds the new 256t string (cid).
Future<void> save() async {
if (busy || !hasUnsavedChanges || editor.text.isEmpty) {
return;
}
// The editor is read-only while busy, so this is what gets saved.
final textToSave = editor.text;
setBusy(true);
try {
// The first save asks the user to approve Textpad. Unlike a web page,
// the app keeps running while the browser is open, so nothing has to
// be stored to survive the trip.
if (!account.isConnected) {
showMessage('Waiting for you to approve Textpad on HashBin…');
final outcome = await account.connect();
showAccount();
if (outcome == ConnectResult.denied) {
showMessage("You didn't approve Textpad on HashBin, so nothing was saved.", error: true);
return;
}
if (outcome == ConnectResult.cancelled) {
clearMessage();
return;
}
}
showMessage('Saving…');
// The request body is the text itself. Uploads made with an OAuth
// app are kept for the user's default retention period (set on their
// HashBin account page) and charged to their balance. Texts of 64
// bytes or less are stored inside the 256t string and are free.
final response = await account.send(
'POST',
'/api/content',
headers: {'content-type': 'text/plain; charset=utf-8'},
body: textToSave,
);
Map<String, dynamic> result;
try {
result = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
} catch (_) {
result = {};
}
if (response.statusCode != 200 && response.statusCode != 201) {
if (result['error'] == 'insufficient_balance') {
showMessage(
result['message'] as String? ?? 'Your HashBin balance is too low to save this.',
error: true,
actions: [('Add funds', () => launchUrl(Uri.parse('$hashbinUrl/deposit.html')))],
);
} else {
showMessage('Saving failed: ${result['message'] ?? result['error'] ?? 'HTTP ${response.statusCode}'}', error: true);
}
return;
}
final cid = result['cid'] as String;
setState(() {
currentCid = cid;
savedText = textToSave;
});
final expiresAt = DateTime.tryParse(result['expires_at'] as String? ?? '');
final expires = expiresAt == null || !mounted
? ''
: ' Kept until ${MaterialLocalizations.of(context).formatMediumDate(expiresAt.toLocal())}.';
showMessage('Saved.$expires', actions: [
('Copy link', () => copyLink(cid)),
('View raw text', () => launchUrl(Uri.parse('$contentUrl/$cid'))),
]);
showAccount();
} on NotConnectedException {
showAccount();
showMessage('Your HashBin connection has ended. Tap Save to connect again.', error: true);
} catch (error) {
showMessage('Saving failed: $error', error: true);
} finally {
setBusy(false);
}
}
10. Receiving links
On start-up the app reads any saved tokens, then listens to app_links, which
delivers the link that launched the app followed by every link opened while it runs.
Future<void> start() async {
await account.load();
showAccount();
// app_links delivers the link that launched the app, then every link
// opened while it runs. Links without a 256t string are ignored.
linkSubscription = AppLinks().stringLinkStream.listen(openLink);
}
Run it
You need the Flutter SDK, plus Xcode for iOS or Android Studio for Android.
git clone https://github.com/curtcox/hashbin.org.git
cd hashbin.org/examples/textpad-flutter
flutter run
flutter test # the unit tests
Make your own app from it
-
Pick a URL scheme from a domain you control, written backwards, for example
com.example.notes. Replaceorg.hashbin.textpad.flutterwith it inios/Runner/Info.plistandandroid/app/src/main/AndroidManifest.xml. Change the bundle identifier (in Xcode) andapplicationId(inandroid/app/build.gradle.kts) to match. -
Register an app on the Developers page with the redirect URI
com.example.notes://oauth. - In
lib/main.dart, setredirectUrito that address and the defaultclientIdto your app's client ID.
Against a local HashBin server
Start the server with npm run dev:local in the repository root. On its
Developers page, register an app with the
redirect URI org.hashbin.textpad.flutter://oauth. Then run:
flutter run \
--dart-define=HASHBIN_URL=http://127.0.0.1:8787 \
--dart-define=TEXTPAD_CLIENT_ID=<client ID>
In the Android emulator, the computer is 10.0.2.2 instead of
127.0.0.1. Android normally refuses plain http; the project allows it in
debug builds only (android/app/src/debug/AndroidManifest.xml).
Things to know
- Copied links point to the web. Copy link copies a link to the web version of Textpad, which opens in any browser on any device; pasting it into this app's Open field works too.
- Saving the same text again. Save stays disabled until the text changes. If edited text happens to match something already on HashBin, HashBin keeps the existing copy and extends how long it is kept, which is charged like a new save.
- Saves use the account's default retention. Apps cannot choose how long content is kept; users set their default on the account page.
- Android can close a backgrounded app. If the phone is very short of memory while the approval page is open, Android may close Textpad, and unsaved text would be lost. A production app could keep a draft in storage to guard against this.
Full source
lib/textpad.dart
// 256t strings, links and text decoding for Textpad.
//
// Nothing in this file uses Flutter, so it can be tested with plain Dart
// (see test/textpad_test.dart).
import 'dart:convert';
import 'dart:typed_data';
// ----------------------------------------------------------------------
// Finding a 256t string in a link
// ----------------------------------------------------------------------
/// Finds the 256t string in any of these:
/// - Textpad's own deep link: `org.hashbin.textpad.flutter://open?256t=<cid>`
/// - a web Textpad link: `https://hashbin.org/docs/textpad/app#256t=<cid>`
/// - a raw content link: `https://256t.us/<cid>`
/// - the bare 256t string: `<cid>`
/// Returns null for a link that holds no 256t string.
String? cidFromText(String text) {
final trimmed = text.trim();
final param = RegExp(r'[#?&]256t=([^&#]*)').firstMatch(trimmed);
if (param != null) {
try {
return Uri.decodeComponent(param[1]!);
} on ArgumentError {
return param[1]; // A stray "%" can't be decoded; isValid256t rejects it.
}
}
final contentLink = RegExp(r'^https://256t\.us/([^/?#]*)').firstMatch(trimmed);
if (contentLink != null) {
return contentLink[1];
}
return trimmed.contains('://') ? null : trimmed;
}
// ----------------------------------------------------------------------
// Checking a 256t string
// ----------------------------------------------------------------------
Uint8List base64UrlToBytes(String text) => base64Url.decode(base64Url.normalize(text));
String bytesToBase64Url(List<int> bytes) => base64UrlEncode(bytes).replaceAll('=', '');
/// A 256t string is base64url text in two parts:
/// - 8 characters holding the content's length in bytes
/// (a 6-byte big-endian number);
/// - then the content itself if it is 64 bytes or less, or else the
/// content's SHA-512 hash (always 86 characters).
/// Checking this up front gives a clear message for a mistyped or cut-off
/// link instead of a confusing "not found".
int contentLength(String cid) =>
base64UrlToBytes(cid.substring(0, 8)).fold(0, (total, byte) => total * 256 + byte);
bool isValid256t(String cid) {
if (!RegExp(r'^[A-Za-z0-9_-]{8,94}$').hasMatch(cid)) {
return false;
}
final length = contentLength(cid);
final expectedRest = length <= 64 ? (length * 4 / 3).ceil() : 86;
return cid.length - 8 == expectedRest;
}
/// Short texts are inside the 256t string itself, so there is nothing to
/// download: this returns their bytes, or null for longer content.
Uint8List? inlineBytes(String cid) =>
contentLength(cid) <= 64 ? base64UrlToBytes(cid.substring(8)) : null;
// ----------------------------------------------------------------------
// Turning bytes into text
// ----------------------------------------------------------------------
/// Content on HashBin is just bytes. Textpad only opens bytes that are valid
/// UTF-8 text: utf8.decode throws a FormatException on anything else
/// instead of inserting replacement characters.
String decodeText(List<int> bytes) {
// utf8.decode drops a leading byte order mark. Put it back, so saving
// unchanged text gives back exactly the same bytes and 256t string.
final hasBom = bytes.length >= 3 && bytes[0] == 0xef && bytes[1] == 0xbb && bytes[2] == 0xbf;
final text = (hasBom ? '\uFEFF' : '') + utf8.decode(hasBom ? bytes.sublist(3) : bytes);
// Valid UTF-8 can still be binary data; a NUL character is a reliable
// sign of that, and real text practically never contains one.
if (text.contains('\u0000')) {
throw const FormatException('Content contains NUL characters');
}
return text;
}
lib/hashbin.dart
// A small HashBin OAuth client for Flutter.
//
// It signs the user in with OAuth 2.0 and PKCE in the system browser, keeps
// the tokens in the device's secure storage (the iOS Keychain or Android
// Keystore), renews the access token when it expires, and calls the HashBin
// API as the user. Copy this file into your own app and change the
// arguments passed to the constructor.
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:flutter/services.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart' as http;
import 'textpad.dart';
/// Thrown when the stored sign-in no longer works: it expired, or the user
/// revoked the app on their HashBin account page.
class NotConnectedException implements Exception {}
/// How a sign-in ended: approved, declined with Deny, or the browser closed.
enum ConnectResult { connected, denied, cancelled }
class HashBinAccount {
HashBinAccount({
required this.clientId,
required this.redirectUri,
required this.baseUrl,
required this.scopes,
http.Client? httpClient,
}) : _http = httpClient ?? http.Client();
final String clientId;
final String redirectUri;
final String baseUrl;
final List<String> scopes;
// Tests pass a fake client; the app uses a real one.
final http.Client _http;
static const _tokensKey = 'hashbin.tokens';
static const _storage = FlutterSecureStorage();
// Renew the access token this long before it expires, so a request never
// sets out with a token that runs out on the way.
static const _expiryMargin = Duration(minutes: 1);
String? _accessToken;
String? _refreshToken;
DateTime _expiresAt = DateTime.fromMillisecondsSinceEpoch(0);
Future<void>? _refreshing;
bool get isConnected => _refreshToken != null;
/// Reads the tokens saved by an earlier run of the app.
Future<void> load() async {
final saved = await _storage.read(key: _tokensKey);
if (saved == null) return;
final tokens = jsonDecode(saved) as Map<String, dynamic>;
_accessToken = tokens['accessToken'] as String;
_refreshToken = tokens['refreshToken'] as String;
_expiresAt = DateTime.fromMillisecondsSinceEpoch(tokens['expiresAt'] as int);
}
Future<void> _saveTokens(Map<String, dynamic> response) async {
_accessToken = response['access_token'] as String;
_refreshToken = response['refresh_token'] as String;
_expiresAt = DateTime.now().add(Duration(seconds: response['expires_in'] as int));
await _storage.write(
key: _tokensKey,
value: jsonEncode({
'accessToken': _accessToken,
'refreshToken': _refreshToken,
'expiresAt': _expiresAt.millisecondsSinceEpoch,
}),
);
}
Future<void> _clearTokens() async {
_accessToken = null;
_refreshToken = null;
await _storage.delete(key: _tokensKey);
}
// ----------------------------------------------------------------------
// Signing in
// ----------------------------------------------------------------------
/// Opens HashBin's approval page in an in-app browser and waits for the
/// user to come back.
Future<ConnectResult> connect() async {
// PKCE: a random secret (the verifier) stays in the app, and only its
// SHA-256 hash (the challenge) goes into the sign-in link. Whoever
// exchanges the code for tokens must know the verifier, so a code
// intercepted on the way back to the app is useless to anyone else.
final random = Random.secure();
String randomString(int bytes) =>
bytesToBase64Url(List.generate(bytes, (_) => random.nextInt(256)));
final verifier = randomString(32);
final challenge = bytesToBase64Url(sha256.convert(utf8.encode(verifier)).bytes);
// "state" ties the reply to this request.
final state = randomString(16);
final authorizeUrl = Uri.parse('$baseUrl/oauth/authorize').replace(queryParameters: {
'client_id': clientId,
'redirect_uri': redirectUri,
'response_type': 'code',
'scope': scopes.join(' '),
'state': state,
'code_challenge': challenge,
'code_challenge_method': 'S256',
});
// On iOS this is an ASWebAuthenticationSession, on Android a Custom
// Tab. It closes by itself when HashBin redirects to a link with the
// redirect URI's scheme, and hands that link back here.
final String result;
try {
result = await FlutterWebAuth2.authenticate(
url: authorizeUrl.toString(),
callbackUrlScheme: Uri.parse(redirectUri).scheme,
);
} on PlatformException catch (error) {
if (error.code == 'CANCELED') return ConnectResult.cancelled;
rethrow;
}
final params = Uri.parse(result).queryParameters;
if (params['state'] != state) {
throw Exception("The reply from HashBin doesn't match this sign-in.");
}
if (params['error'] == 'access_denied') {
return ConnectResult.denied;
}
final code = params['code'];
if (params['error'] != null || code == null) {
throw Exception(params['error'] ?? "HashBin didn't send an authorization code.");
}
// Trade the one-time code (plus the verifier) for tokens.
final response = await _http.post(
Uri.parse('$baseUrl/oauth/token'),
headers: {'content-type': 'application/json'},
body: jsonEncode({
'grant_type': 'authorization_code',
'client_id': clientId,
'redirect_uri': redirectUri,
'code': code,
'code_verifier': verifier,
}),
);
final body = _jsonObject(response);
if (response.statusCode != 200) {
throw Exception(body['message'] ?? body['error'] ?? 'HTTP ${response.statusCode}');
}
await _saveTokens(body);
return ConnectResult.connected;
}
/// Ends the app's access: forgets the tokens here and revokes the refresh
/// token on the server, so a copy of it can't be used either.
Future<void> disconnect() async {
final refreshToken = _refreshToken;
await _clearTokens();
if (refreshToken == null) return;
try {
await _http.post(
Uri.parse('$baseUrl/oauth/revoke'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'token': refreshToken, 'token_type_hint': 'refresh_token'}),
);
} catch (_) {
// Offline: the token still expires on its own within 30 days.
}
}
// ----------------------------------------------------------------------
// Calling the API
// ----------------------------------------------------------------------
/// Access tokens last an hour. A refresh token gets a new one, and is
/// itself replaced: each refresh token works only once. Calls that need a
/// new token at the same moment share one refresh, because a second
/// refresh with the same (by then used) token would fail.
Future<void> _refreshAccessToken() {
return _refreshing ??= _renewTokens().whenComplete(() => _refreshing = null);
}
Future<void> _renewTokens() async {
final response = await _http.post(
Uri.parse('$baseUrl/oauth/token'),
headers: {'content-type': 'application/json'},
body: jsonEncode({'grant_type': 'refresh_token', 'refresh_token': _refreshToken}),
);
final body = _jsonObject(response);
if (body['error'] == 'invalid_grant') {
// Expired or revoked: the user has to approve the app again.
await _clearTokens();
throw NotConnectedException();
}
if (response.statusCode != 200) {
throw Exception(body['message'] ?? body['error'] ?? 'HTTP ${response.statusCode}');
}
await _saveTokens(body);
}
/// Sends a request to a HashBin API path as the signed-in user.
Future<http.Response> send(String method, String path,
{Map<String, String> headers = const {}, String? body}) async {
if (!isConnected) {
throw NotConnectedException();
}
if (DateTime.now().isAfter(_expiresAt.subtract(_expiryMargin))) {
await _refreshAccessToken();
if (!isConnected) {
throw NotConnectedException(); // Disconnected while renewing.
}
}
final request = http.Request(method, Uri.parse('$baseUrl$path'))
..headers.addAll(headers)
..headers['authorization'] = 'Bearer $_accessToken';
if (body != null) {
request.bodyBytes = utf8.encode(body);
}
final response = await http.Response.fromStream(await _http.send(request));
if (response.statusCode == 401) {
await _clearTokens();
throw NotConnectedException();
}
return response;
}
}
/// The JSON object in a response body, or an empty map if there isn't one.
Map<String, dynamic> _jsonObject(http.Response response) {
try {
final decoded = jsonDecode(utf8.decode(response.bodyBytes));
return decoded is Map<String, dynamic> ? decoded : {};
} on FormatException {
return {};
}
}
lib/main.dart
// Textpad for Flutter: open text from a 256t link, edit it, and save it to
// the user's HashBin account.
//
// A walkthrough of this code is at https://hashbin.org/docs/textpad-flutter
import 'dart:async';
import 'dart:convert';
import 'package:app_links/app_links.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'hashbin.dart';
import 'textpad.dart';
// ----------------------------------------------------------------------
// 1. Configuration
// ----------------------------------------------------------------------
// The HashBin service, and the domain that serves published content.
// Development only: --dart-define=HASHBIN_URL=... points the app at a local
// HashBin server (npm run dev:local), which serves content itself.
const hashbinUrl = String.fromEnvironment('HASHBIN_URL', defaultValue: 'https://hashbin.org');
const contentUrl = String.fromEnvironment('HASHBIN_URL', defaultValue: 'https://256t.us');
// The client ID from registering Textpad at https://hashbin.org/developers,
// or --dart-define=TEXTPAD_CLIENT_ID=... for an app registered on a local
// server.
const clientId = String.fromEnvironment(
'TEXTPAD_CLIENT_ID',
defaultValue: 'app_1a74e62b-d752-4a3c-81af-5c850af18c5b',
);
// HashBin sends the user back to this link after they approve Textpad. Its
// scheme is the app's own, so the system hands the link to this app. It
// must match a registered redirect URI exactly.
const redirectUri = 'org.hashbin.textpad.flutter://oauth';
// What Textpad asks the user to allow: publishing (billed to their
// balance) and reading their balance so it can be shown.
const scopes = ['content:write', 'balance:read'];
final account = HashBinAccount(
clientId: clientId,
redirectUri: redirectUri,
baseUrl: hashbinUrl,
scopes: scopes,
);
void main() => runApp(const TextpadApp());
class TextpadApp extends StatelessWidget {
const TextpadApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Textpad',
theme: ThemeData(colorSchemeSeed: Colors.blue),
darkTheme: ThemeData(colorSchemeSeed: Colors.blue, brightness: Brightness.dark),
home: const TextpadScreen(),
);
}
}
/// The message above the editor, with optional buttons such as "Add funds".
class Message {
const Message(this.text, {this.error = false, this.actions = const []});
final String text;
final bool error;
final List<(String, VoidCallback)> actions;
}
class TextpadScreen extends StatefulWidget {
const TextpadScreen({super.key});
@override
State<TextpadScreen> createState() => _TextpadScreenState();
}
class _TextpadScreenState extends State<TextpadScreen> {
// --------------------------------------------------------------------
// 2. Screen state
// --------------------------------------------------------------------
final editor = TextEditingController();
final linkField = TextEditingController();
// The 256t string of the text in the editor, and that text exactly as it
// was opened or last saved. Comparing it with the editor tells us whether
// there are unsaved changes. Both are null for a new, unsaved text.
String? currentCid;
String? savedText;
// Set while a load or save is running, to ignore extra taps.
bool busy = false;
Message? message;
bool connected = false;
String accountLabel = '';
StreamSubscription<String>? linkSubscription;
bool get hasUnsavedChanges => editor.text != (savedText ?? '');
void showMessage(String text, {bool error = false, List<(String, VoidCallback)> actions = const []}) {
setState(() => message = Message(text, error: error, actions: actions));
}
void clearMessage() => setState(() => message = null);
void setBusy(bool value) => setState(() => busy = value);
/// Shows text in the editor as the saved version of cid.
void setDocument(String? cid, String text) {
editor.text = text;
setState(() {
currentCid = cid;
savedText = text;
});
}
// --------------------------------------------------------------------
// 3. Opening a 256t link
// --------------------------------------------------------------------
/// Asks before throwing away unsaved changes. Returns true to go on.
Future<bool> confirmDiscard() async {
if (!hasUnsavedChanges) return true;
final discard = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Discard your unsaved changes?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Keep editing')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Discard')),
],
),
);
return discard ?? false;
}
/// Opens a deep link, a web Textpad link, a 256t.us link or a bare 256t
/// string. fromUser is true when it came from the "Open" field, where a
/// link without a 256t string deserves a message rather than silence.
Future<void> openLink(String link, {bool fromUser = false}) async {
final cid = cidFromText(link);
if (cid == null) {
if (fromUser) {
showMessage("That link doesn't contain a 256t string.", error: true);
}
return;
}
if (busy || !await confirmDiscard()) {
return;
}
linkField.clear();
await openCid(cid);
}
Future<void> openCid(String cid) async {
// Whatever happens below, don't leave the previous text on screen as if
// it belonged to this link.
setDocument(null, '');
clearMessage();
if (cid.isEmpty) {
showMessage('This link has "256t=" but no 256t string after it.', error: true);
return;
}
if (!isValid256t(cid)) {
final shown = cid.length > 100 ? '${cid.substring(0, 100)}…' : cid;
showMessage('"$shown" isn\'t a valid 256t string. Check that the link was copied completely.', error: true);
return;
}
// Short texts are inside the 256t string itself.
final inline = inlineBytes(cid);
if (inline != null) {
showText(cid, inline);
return;
}
setBusy(true);
showMessage('Loading…');
try {
final http.Response response;
try {
response = await http.get(Uri.parse('$contentUrl/$cid'));
} catch (_) {
showMessage("Couldn't reach HashBin to load this text. Check your connection and try again.", error: true);
return;
}
switch (response.statusCode) {
case 200:
showText(cid, response.bodyBytes);
case 404:
showMessage('Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved.', error: true);
case 451:
showMessage('This text is unavailable while a dispute about it is reviewed.', error: true);
// Each stored text has a download allowance; when it runs out, 256t.us answers 429.
case 429:
showMessage('HashBin is limiting how often this text can be downloaded. Try again later.', error: true);
default:
showMessage("HashBin couldn't return this text (HTTP ${response.statusCode}). Try again later.", error: true);
}
} finally {
setBusy(false);
}
}
void showText(String cid, List<int> bytes) {
try {
setDocument(cid, decodeText(bytes));
clearMessage();
} on FormatException {
showMessage("This 256t string points to content that isn't text, so Textpad can't open it.", error: true);
}
}
// --------------------------------------------------------------------
// 4. The user's HashBin account
// --------------------------------------------------------------------
Future<void> showAccount() async {
setState(() {
connected = account.isConnected;
accountLabel = connected ? 'Connected to HashBin' : 'Not connected to HashBin';
});
if (!connected) return;
try {
final response = await account.send('GET', '/api/balance');
if (response.statusCode == 200) {
final balanceCents = jsonDecode(utf8.decode(response.bodyBytes))['balance_cents'] as num;
setState(() => accountLabel = 'HashBin balance: \$${(balanceCents / 100).toStringAsFixed(2)}');
}
} on NotConnectedException {
showAccount();
} catch (_) {
// Offline: keep showing "Connected to HashBin".
}
}
Future<void> disconnect() async {
await account.disconnect();
showAccount();
}
// --------------------------------------------------------------------
// 5. Saving text
// --------------------------------------------------------------------
Future<void> save() async {
if (busy || !hasUnsavedChanges || editor.text.isEmpty) {
return;
}
// The editor is read-only while busy, so this is what gets saved.
final textToSave = editor.text;
setBusy(true);
try {
// The first save asks the user to approve Textpad. Unlike a web page,
// the app keeps running while the browser is open, so nothing has to
// be stored to survive the trip.
if (!account.isConnected) {
showMessage('Waiting for you to approve Textpad on HashBin…');
final outcome = await account.connect();
showAccount();
if (outcome == ConnectResult.denied) {
showMessage("You didn't approve Textpad on HashBin, so nothing was saved.", error: true);
return;
}
if (outcome == ConnectResult.cancelled) {
clearMessage();
return;
}
}
showMessage('Saving…');
// The request body is the text itself. Uploads made with an OAuth
// app are kept for the user's default retention period (set on their
// HashBin account page) and charged to their balance. Texts of 64
// bytes or less are stored inside the 256t string and are free.
final response = await account.send(
'POST',
'/api/content',
headers: {'content-type': 'text/plain; charset=utf-8'},
body: textToSave,
);
Map<String, dynamic> result;
try {
result = jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>;
} catch (_) {
result = {};
}
if (response.statusCode != 200 && response.statusCode != 201) {
if (result['error'] == 'insufficient_balance') {
showMessage(
result['message'] as String? ?? 'Your HashBin balance is too low to save this.',
error: true,
actions: [('Add funds', () => launchUrl(Uri.parse('$hashbinUrl/deposit.html')))],
);
} else {
showMessage('Saving failed: ${result['message'] ?? result['error'] ?? 'HTTP ${response.statusCode}'}', error: true);
}
return;
}
final cid = result['cid'] as String;
setState(() {
currentCid = cid;
savedText = textToSave;
});
final expiresAt = DateTime.tryParse(result['expires_at'] as String? ?? '');
final expires = expiresAt == null || !mounted
? ''
: ' Kept until ${MaterialLocalizations.of(context).formatMediumDate(expiresAt.toLocal())}.';
showMessage('Saved.$expires', actions: [
('Copy link', () => copyLink(cid)),
('View raw text', () => launchUrl(Uri.parse('$contentUrl/$cid'))),
]);
showAccount();
} on NotConnectedException {
showAccount();
showMessage('Your HashBin connection has ended. Tap Save to connect again.', error: true);
} catch (error) {
showMessage('Saving failed: $error', error: true);
} finally {
setBusy(false);
}
}
/// A link to the web version of Textpad opens the text in any browser,
/// and pasting it into this app's "Open" field works too.
Future<void> copyLink(String cid) async {
await Clipboard.setData(ClipboardData(text: '$hashbinUrl/docs/textpad/app#256t=$cid'));
showMessage('Link copied. Anyone with it can read this text.');
}
Future<void> newDocument() async {
if (busy || !await confirmDiscard()) {
return;
}
setDocument(null, '');
clearMessage();
}
// --------------------------------------------------------------------
// 6. Wiring it together
// --------------------------------------------------------------------
@override
void initState() {
super.initState();
// Rebuild as the user types, so the Save button and the "Unsaved
// changes" label stay current.
editor.addListener(() => setState(() {}));
linkField.addListener(() => setState(() {}));
start();
}
Future<void> start() async {
await account.load();
showAccount();
// app_links delivers the link that launched the app, then every link
// opened while it runs. Links without a 256t string are ignored.
linkSubscription = AppLinks().stringLinkStream.listen(openLink);
}
@override
void dispose() {
linkSubscription?.cancel();
editor.dispose();
linkField.dispose();
super.dispose();
}
// --------------------------------------------------------------------
// 7. The screen
// --------------------------------------------------------------------
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final muted = TextStyle(color: colors.onSurfaceVariant);
var savedState = '';
if (currentCid != null && !hasUnsavedChanges) {
savedState = 'Saved as 256t:${currentCid!.substring(0, 12)}…';
} else if (currentCid != null) {
savedState = 'Unsaved changes';
} else if (editor.text.isNotEmpty) {
savedState = 'Not saved yet';
}
return Scaffold(
appBar: AppBar(
title: const Text('Textpad'),
actions: [
if (connected) TextButton(onPressed: busy ? null : disconnect, child: const Text('Disconnect')),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(accountLabel, style: muted),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextField(
controller: linkField,
decoration: const InputDecoration(
hintText: 'Paste a 256t string or link',
border: OutlineInputBorder(),
isDense: true,
),
autocorrect: false,
textInputAction: TextInputAction.go,
onSubmitted: (text) => openLink(text, fromUser: true),
),
),
const SizedBox(width: 8),
FilledButton.tonal(
onPressed: busy || linkField.text.trim().isEmpty
? null
: () => openLink(linkField.text, fromUser: true),
child: const Text('Open'),
),
],
),
if (message != null) ...[
const SizedBox(height: 8),
Semantics(
liveRegion: true,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: message!.error ? colors.errorContainer : colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message!.text),
if (message!.actions.isNotEmpty)
Wrap(
spacing: 8,
children: [
for (final (label, onPressed) in message!.actions)
TextButton(onPressed: onPressed, child: Text(label)),
],
),
],
),
),
),
],
const SizedBox(height: 8),
Expanded(
child: TextField(
controller: editor,
readOnly: busy,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
keyboardType: TextInputType.multiline,
style: const TextStyle(fontFamily: 'monospace', fontFamilyFallback: ['Menlo', 'Courier']),
decoration: const InputDecoration(
hintText: 'Type or paste text here.',
border: OutlineInputBorder(),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(child: Text(savedState, style: muted, overflow: TextOverflow.ellipsis)),
OutlinedButton(onPressed: busy ? null : newDocument, child: const Text('New')),
const SizedBox(width: 8),
FilledButton(
onPressed: busy || !hasUnsavedChanges || editor.text.isEmpty ? null : save,
child: const Text('Save'),
),
],
),
),
],
),
),
),
);
}
}
pubspec.yaml
name: textpad
description: "Textpad: edit text stored on HashBin"
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1
environment:
sdk: ^3.12.2
# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
flutter:
sdk: flutter
# The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8
http: ^1.6.0
crypto: ^3.0.7
flutter_secure_storage: ^11.2.0
flutter_web_auth_2: ^5.1.0
app_links: ^7.2.2
url_launcher: ^6.3.3
dev_dependencies:
flutter_test:
sdk: flutter
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is
# activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
# The following section is specific to Flutter packages.
flutter:
# The following line ensures that the Material Icons font is
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg
# An image asset can refer to one or more resolution-specific "variants", see
# https://flutter.dev/to/resolution-aware-images
# For details regarding adding assets from package dependencies, see
# https://flutter.dev/to/asset-from-package
# To add custom fonts to your application, add a fonts section here,
# in this "flutter" section. Each entry in this list should have a
# "family" key with the font family name, and a "fonts" key with a
# list giving the asset and other descriptors for the font. For
# example:
# fonts:
# - family: Schyler
# fonts:
# - asset: fonts/Schyler-Regular.ttf
# - asset: fonts/Schyler-Italic.ttf
# style: italic
# - family: Trajan Pro
# fonts:
# - asset: fonts/TrajanPro.ttf
# - asset: fonts/TrajanPro_Bold.ttf
# weight: 700
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Textpad loads and saves text over the network. -->
<uses-permission android:name="android.permission.INTERNET"/>
<application
android:label="Textpad"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Links to a text, org.hashbin.textpad.flutter://open?256t=...,
open Textpad. The app_links package passes them to the app. -->
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.hashbin.textpad.flutter" android:host="open"/>
</intent-filter>
<!-- The app_links package handles incoming links, not Flutter's router. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false"/>
</activity>
<!-- Catches org.hashbin.textpad.flutter://oauth?code=... at the end of
signing in to HashBin and hands it to flutter_web_auth_2. -->
<activity
android:name="com.linusu.flutter_web_auth_2.CallbackActivity"
android:exported="true"
android:taskAffinity="">
<intent-filter android:label="flutter_web_auth_2">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="org.hashbin.textpad.flutter" android:host="oauth"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
ios/Runner/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Textpad</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Textpad</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<!-- Links starting org.hashbin.textpad.flutter:// open this app:
"open?256t=..." links to a text, and "oauth?code=..." at the
end of signing in to HashBin. -->
<key>CFBundleURLName</key>
<string>org.hashbin.textpad.flutter</string>
<key>CFBundleURLSchemes</key>
<array>
<string>org.hashbin.textpad.flutter</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<!-- The app_links package handles incoming links, not Flutter's router. -->
<key>FlutterDeepLinkingEnabled</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>