This repository contains a scaffolding of the app described in tewa_specification.md — a Flutter UI with platform
integration stubs to export Twitch emotes as WhatsApp sticker packs.
What’s implemented now:
- Flutter app shell with bottom navigation (Channels, Favorites, Packs, Search, Settings)
- Hive initialized with TypeAdapters registered and boxes opened: Channel (1), EmotePack (2), Favorite (3), Sticker (4), StickerExportState (5), StickerPack (6)
- Emote (7) TypeAdapter added
- Boxes opened at startup: channels, emotes, favorites, emote_packs, sticker_packs
- Emote metadata persisted to Hive and loaded on app start
- Favorites persisted to Hive with in‑UI star toggle (Channels) and Favorites grid
- EmotePack and StickerPack management:
- Create EmotePack drafts from Favorites, rename, and delete
- Export an EmotePack via Rust to produce a WhatsApp‑ready StickerPack (files + manifest)
- Persist exported StickerPacks in Hive and list them in the Packs tab
- Android: WhatsApp integration via a
ContentProviderper official contract (implemented)- The provider now enumerates exported packs by reading
manifest.jsonunderfiles/stickers/{pack_id}/ - It returns real cursors for packs and stickers and serves
tray.webpand sticker files viacontent://URIs
- The provider now enumerates exported packs by reading
- iOS: WAStickersThirdParty import trigger implemented (official path)
LSApplicationQueriesSchemesconfigured forwhatsappandwhatsapp-business- Settings → “Import to WhatsApp (iOS)” opens WhatsApp’s sticker import flow for the latest exported pack
- Basic widget test updated to reflect the new app shell
- Twitch OAuth login using Authorization Code + PKCE (no client secret on device)
- Secure token storage (Keychain/Keystore) with refresh handling
- Twitch API client for “Get User” and “Get User Emotes” (scope
user:read:emotes) - Settings screen: Sign in/Sign out + “Sync Emotes” action with status
- Channels screen: Sign-in CTA and a grid of synced emotes with favorites toggle
- Channel grouping UI (by emote owner) with tabs per channel and an "All" tab
- Search tab: global search across stored emotes (case/diacritic‑insensitive) with favorites toggle
- Rust core (static + animated) with FFI bridge:
- Static:
tewa_convert_emotes_to_stickersperforms decode → sRGB → Lanczos3 resize → optional 2px outline → lossy WebP with size targeting (≤ 100 KB) → tray.webp (96×96, ≤ 50 KB) → manifest.json - Animated: GIF and animated WebP inputs are decoded/demuxed to frames, capped to ≤ 30 fps and ≤ 3 s total, resized/outlined per frame, and assembled into Animated WebP with quality search ≤ 500 KB. Per-sticker is_animated is recorded in manifest.
tewa_validate_stickerenforces dimensions and size caps (tray vs. sticker) and, for animated WebP, also enforces ≤ 3 s total duration and ≤ 30 fps by parsing the animation timeline- Settings → “Rust Core → Check” calls
tewa_version()to verify the native library is loaded
- Static:
- Export sample flow: Settings → “Export sample pack (Rust)” downloads 3 emotes and generates a WhatsApp‑ready pack in the app’s support directory
- Diagnostics screen: Settings → Diagnostics shows login state, counts, last sync error, Rust core version, and cache sizes; buttons to clear temp cache and sticker outputs
- About page: Settings → About with attributions and a tappable Privacy Policy URL (configure in assets/config)
- First‑run onboarding: A short, non‑blocking modal sheet explains usage and rights (personal use only, export while
entitled, official APIs). You can revisit guidance by clearing the
prefsbox. - Minimum tap target sizes: The app theme enables
MaterialTapTargetSize.paddedglobally; key action buttons are ElevatedButton/Navigation destinations which meet size guidance. - Semantics and labels: Emote tiles in Channels, Favorites, and Search include Semantics labels; favorite toggles and pack actions have semantic labels and tooltips to improve screen‑reader support.
- Dynamic text sizing: UI avoids hard‑coded sizes where practical and uses ellipsis/overflow to remain usable with larger fonts; grid tiles remain tappable. We will continue to refine based on user feedback.
This app uses Twitch OAuth 2.0 Authorization Code with PKCE. No client secret is stored in the app; only the public client ID is needed. Follow these steps to get login working and understand the flow.
Prerequisites (one‑time setup for developers/testers)
- Create or use an existing Twitch application at https://dev.twitch.tv/console/apps.
- Copy the Client ID from your Twitch app.
- Add the redirect URI tewa://oauth/callback to the “OAuth Redirect URLs” list for your Twitch app.
- Provide the Client ID to the app in one of two ways:
- Preferred: Create a file assets/config/app_settings.json (bundled locally, not committed if it contains secrets)
with at least:
The project is configured to bundle the entire assets/config/ directory.
"twitch": { "clientId": "YOUR_REAL_TWITCH_CLIENT_ID", "redirectUris": { "android": "tewa://oauth/callback", "ios": "tewa://oauth/callback" } } } - Alternatively (for debugging): write a secure override to key tw_client_id_override in FlutterSecureStorage. The app will prefer this override over the asset config.
- Preferred: Create a file assets/config/app_settings.json (bundled locally, not committed if it contains secrets)
with at least:
Important: Placeholder values like "YOUR_CLIENT_ID" are treated as invalid and will cause the app to show a clear setup error instead of hitting Twitch.
User steps in the app
- Build and run the app after configuring the Client ID.
- Open the Channels tab (or Settings). If you’re not signed in, you’ll see a “Sign in” button.
- Tap “Sign in”. The app opens the Twitch authorization page in a browser or custom tab.
- Log in to Twitch (if not already) and review the requested scope user:read:emotes.
- Approve access. Twitch redirects to tewa://oauth/callback, which returns control to the app.
- The app exchanges the authorization code for tokens (using PKCE), stores them securely (Keychain/Keystore), and marks you as signed in. The Channels tab can now sync and show your emotes.
What happens under the hood
- Authorization: The app builds an authorize URL with your effective Client ID, the redirect URI tewa://oauth/callback, response_type=code, scope=user:read:emotes, and a PKCE S256 code challenge. force_verify=true ensures the consent screen can appear.
- Callback handling: FlutterWebAuth2 listens for the tewa scheme and returns the final callback URL to the app.
- Token exchange: The app posts to https://id.twitch.tv/oauth2/token with client_id, code, redirect_uri, and the PKCE code_verifier. Access and refresh tokens are stored securely.
- Token refresh: When needed, the app refreshes tokens using the same Client ID. All Twitch API calls include the Authorization: Bearer header and the matching Client-Id header.
Troubleshooting
- “invalid client” immediately after tapping Sign in: The effective Client ID is missing or a placeholder. Ensure assets/config/app_settings.json contains your real Client ID, or set the secure override tw_client_id_override. Also confirm your Twitch app lists tewa://oauth/callback as a valid redirect URI.
- Redirect never returns to the app: Make sure the redirect URI is exactly tewa://oauth/callback and that the app is installed/built with the intent filter (Android) and URL scheme (iOS). This repo already configures the scheme; only the Twitch app needs to be set.
- Stuck or canceled auth: Simply try again. You can also sign out in Settings to clear tokens and then sign in.
- API calls failing after some time: The app refreshes tokens automatically. If refresh fails (e.g., revoked), you’ll be signed out and can sign in again.
Notes
- The app consistently uses a single “effective” Client ID across login, refresh, and API calls. If you set a secure override, it will be used everywhere; otherwise, the value from assets/config/app_settings.json is used. Placeholders/whitespace are ignored.
- No client secret is required or stored; this is a public client using PKCE.
This repo now contains a minimal Rust library (FFI) scaffold in rust_core/ and a Dart bridge in
lib/ffi/tewa_rust.dart.
What works now
tewa_version()is exported and invoked from Flutter via the Settings screen → “Rust Core” → “Check” button. If the native library is missing, a helpful message is shown.tewa_convert_emotes_to_stickers(json, outDir)performs conversion and writes files to{appSupport}/stickers/{pack_id}(Android maps to app files dir). It outputs:0001.webp,0002.webp, …,tray.webp, andmanifest.json.- Static stickers: ≤ 100 KB each; 512×512.
- Animated stickers (GIF inputs): ≤ 500 KB each; ≤ 3 s; ≤ 30 fps; 512×512.
tewa_validate_sticker(path)validates basic constraints for static/tray.
Planned implementation (see tewa_specification.md)
- Pack manifest fields extended as needed; full Android/iOS import flows.
Prereqs
- Rust toolchain (
rustup), andcargo-ndkinstalled:cargo install cargo-ndk - Android NDK (Flutter embeds an NDK; align with your local setup if needed)
From project root:
cd rust_core
cargo ndk -t arm64-v8a -o ../android/app/src/main/jniLibs build --release
cargo ndk -t armeabi-v7a -o ../android/app/src/main/jniLibs build --release
cargo ndk -t x86_64 -o ../android/app/src/main/jniLibs build --release
This will place .so files under:
android/app/src/main/jniLibs/arm64-v8a/librust_core.soandroid/app/src/main/jniLibs/armeabi-v7a/librust_core.soandroid/app/src/main/jniLibs/x86_64/librust_core.so
Run the app on Android and open Settings → Rust Core → Check. You should see a snackbar like “Loaded: tewa_rust 0.1.0”.
On iOS, the Rust library must be linked statically into the app and loaded via DynamicLibrary.process().
Steps to produce an xcframework and link it in Xcode:
- Install iOS Rust targets
rustup target add aarch64-apple-ios x86_64-apple-ios aarch64-apple-ios-sim
- Build static libraries for device and simulators
cd rust_core
cargo build --release --target aarch64-apple-ios
cargo build --release --target x86_64-apple-ios
cargo build --release --target aarch64-apple-ios-sim
This produces:
- target/aarch64-apple-ios/release/librust_core.a
- target/x86_64-apple-ios/release/librust_core.a
- target/aarch64-apple-ios-sim/release/librust_core.a
- Create an xcframework
mkdir -p build_ios
cp target/aarch64-apple-ios/release/librust_core.a build_ios/librust_core_ios_arm64.a
cp target/x86_64-apple-ios/release/librust_core.a build_ios/librust_core_ios_x86_64.a
cp target/aarch64-apple-ios-sim/release/librust_core.a build_ios/librust_core_ios_sim_arm64.a
xcodebuild -create-xcframework \
-library build_ios/librust_core_ios_arm64.a -headers . \
-library build_ios/librust_core_ios_x86_64.a -headers . \
-library build_ios/librust_core_ios_sim_arm64.a -headers . \
-output build_ios/rust_core.xcframework
- Add the xcframework to Xcode
- Open
ios/Runner.xcworkspacein Xcode. - Drag
build_ios/rust_core.xcframeworkinto the Runner project (e.g., under a new "Frameworks" group). Choose "Copy items if needed". - Select the Runner target → General → Frameworks, Libraries, and Embedded Content → add
rust_core.xcframeworkand set it to "Do Not Embed".
- Ensure symbols are visible to Flutter
- No additional Dart-side change is required;
lib/ffi/tewa_rust.dartloads symbols from the current process on iOS. - Build and run on a device/simulator, then open Settings → Rust Core → Check; it should show the version string.
Notes:
- If you see a linker error, verify the library name is
librust_core.a(matchingcrate-type = ["staticlib"]) and that the xcframework is added to the correct target. - The
libwebpvendored bylibwebp-sys2is statically linked into the Rust lib. - We’ll automate xcframework creation in CI in a later step.
- The bridge is in
lib/ffi/tewa_rust.dart. - On Android it dynamically loads
librust_core.so. - On iOS it loads from the current process (requires static linking).
- API surface today:
TewaRustCore.instance.version()→ String? (null if not loaded)TewaRustCore.instance.validateSticker(path)→ int? (0 = ok)TewaRustCore.instance.convertEmotesToStickers(json, outDir)→ int?
- Sign in with Twitch (Settings → Sign in) and Sync Emotes so at least 3 emotes are available.
- Build and place the Rust library on Android (see below), or link on iOS.
- Open Settings → “Export sample pack (Rust)”.
- On success, files are written under:
- Android:
<app files>/stickers/<pack_id>/ - iOS:
Library/Application Support/TEWA/stickers/<pack_id>/
- Android:
- On Android, trigger WhatsApp import using the ContentProvider (see spec Appendix A for the intent). The provider
exposes (authority is based on your applicationId):
- Packs:
content://${applicationId}.stickerprovider/packs - Stickers of a pack:
content://${applicationId}.stickerprovider/packs/{pack_id}/stickers - Tray icon bytes:
content://${applicationId}.stickerprovider/packs/{pack_id}/tray - Sticker file bytes:
content://${applicationId}.stickerprovider/packs/{pack_id}/sticker/{file_name}
- Packs:
- On iOS, open Settings → “Import to WhatsApp (iOS)” to launch WhatsApp’s WAStickersThirdParty import flow for the most recently exported pack.
- From Packs → Draft Emote Packs, tap “Preview & Export”. The app will:
- Run a conversion into a preview directory using the Rust core.
- Validate the tray (96×96, ≤ 50 KB) and each sticker (static ≤ 100 KB; animated ≤ 500 KB, ≤ 3 s, ≤ 30 fps; 512×512).
- Show a per‑file OK/Error list with human‑readable reasons (size/dimensions/fps/duration/decoder issues).
- Block finalization until all items pass validation. You can re‑run the preview after adjusting the draft.
- When all items pass, tap “Finalize export” to persist the StickerPack and move files into the final pack directory.
- Go to the Favorites tab and star at least 3 emotes.
- Open the Packs tab:
- Tap “New from Favorites” to create an EmotePack draft from your current favorites.
- Rename the draft if desired (max 10 chars).
- Tap Export to run the Rust converter and generate a WhatsApp‑ready pack. The exported pack will appear under “Exported Sticker Packs”.
- Import into WhatsApp:
- Android: use the ContentProvider import intent (see spec Appendix A) from your app flow.
- iOS: use Settings → “Import to WhatsApp (iOS)”.
This repo includes a GitHub Actions workflow that runs on push and PR to main/master:
- Format check:
dart format --set-exit-if-changed . - Static analysis:
flutter analyze - Tests:
flutter test - Android build:
flutter build apk --debug
Workflow file: .github/workflows/ci.yml
To see CI status in your repo, you can add a badge like:

- Open Settings → Diagnostics to view:
- Login status and user label
- Counts for stored Emotes, Favorites, Emote drafts, and exported Sticker packs
- The last emote sync error (if any)
- Rust core version (if the native library is loaded)
- Cache usage: Temporary cache and Sticker outputs directories
- Use the Clear buttons to remove temporary files or previously exported sticker artifacts.
- Open Settings → About to see attributions and legal notes.
- Configure your Privacy Policy URL in
assets/config/app_settings.example.jsonunderlegal.privacyPolicyUrl.
- Requirements
- Flutter stable, Android SDK, Xcode
- Rust toolchain (for later FFI work)
- Install deps
flutter pub get
- Config placeholder
- See
assets/config/app_settings.example.json. Copy and adapt as needed. Secrets (like Twitch Client ID) should be stored using in‑app encrypted storage; do not commit real values.
- Run
flutter run
-
Create a Twitch application (https://dev.twitch.tv/console/apps)
- Add OAuth Redirect URL:
tewa://oauth/callback(for both Android and iOS) - Copy your Client ID.
- Add OAuth Redirect URL:
-
Configure the app
- Edit
assets/config/app_settings.example.jsonand settwitch.clientIdto your Client ID. - The redirect URI defaults to
tewa://oauth/callbackand is already wired in AndroidManifest and iOS Info.plist.
- Edit
-
Login
- Open the app → Settings tab → press “Sign in”. Complete Twitch login/consent; tokens are stored securely.
-
Sync emotes
- In Settings, tap “Sync” under “Sync Emotes” or press the “Sync” FAB on Channels.
- The Channels tab will show a grid of your available Twitch emotes.
Scopes and endpoints
- Scope:
user:read:emotes - Endpoints:
GET /helix/users,GET /helix/users/emotes
Troubleshooting
- If login is not returning to the app, ensure the scheme
tewa://is not claimed by another app on your device/emulator. - Verify your Twitch app redirect URI is exactly
tewa://oauth/callback. - Android requires INTERNET permission (declared). iOS requires the custom URL scheme (added).
- Manifest registers
com.example.tewa.stickers.StickerContentProviderwith authority${applicationId}.stickerprovider. - The provider computes its authority at runtime from the app package name to ensure it always matches the current applicationId declared in Gradle.
- The provider reads exported packs by parsing
manifest.jsonunderfiles/stickers/{pack_id}/and returns real cursors for packs and stickers, servingtray.webpand sticker files viacontent://URIs.
Info.plistincludes WhatsApp URL schemes and the OAuth callback URL type (schemetewa).- WAStickersThirdParty import trigger is wired: use Settings → “Import to WhatsApp (iOS)”. Ensure you have exported a pack first.
- See
tewa_specification.mdfor the full product/tech specification and platform checklists.