Tootsie - Mastodon Client for Android
Tootsie is a modern, high-performance, and privacy-respecting Mastodon client for Android. Built from the ground up with Jetpack Compose and Kotlin, it follows Clean Architecture principles to provide a smooth experience for the Fediverse.
🧪 Testers wanted
Google requires a closed test with at least 12 testers for 14 days before Tootsie can be published on Google Play. You can help:
- Join the Google group tootsie-testing (with the Google account of your Play Store)
- Opt in as a tester: https://play.google.com/apps/testing/com.pfitzer.tootsie
- Install Tootsie from the Play Store and keep it installed for at least 14 days
Bugs and ideas are welcome as issues. Without the Play Store, install the APK from the latest release.
📱 Screenshots
Dark mode
🚀 Features
- Multi-Account Support: Seamlessly switch between multiple Mastodon accounts without app reloads.
- Modern UI: Fully built with Jetpack Compose and Material Design 3, with adjustable text size.
- Offline-First Timelines: Home, local and federated timelines are loaded into a Room database (Paging
RemoteMediator) and stay fully scrollable without a network connection. - Privacy First:
- UnifiedPush notifications, no Firebase/FCM required. Payloads are decrypted on the device (RFC 8291 Web Push encryption); a WorkManager polling fallback covers all accounts when no distributor is available.
- Access tokens are encrypted at rest with a non-exportable Android Keystore key (AES-GCM), in DataStore and in the Room database.
- Posts & Threads:
- Favourite, boost, bookmark and reply; the state updates immediately in every list.
- Edit own posts (text, content warning, media and alt texts, poll), see the edit history of edited posts, delete or delete & redraft.
- See who favourited and boosted a post.
- Translate posts in other languages through the server's translation service, or hand them to a translator app.
- Quote posts: boost or quote a post, with the author's quote policy respected (Mastodon 4.5+).
- Content warnings hide text, media and polls until expanded; sensitive media is blurred.
- Threads automatically load missing replies from other servers (Mastodon 4.5+).
- Polls (view, vote, create), custom emoji picker, draft auto-save and management (media, poll and language included); mark media as sensitive.
- Rich Media: Images, videos and audio (ExoPlayer); uncached remote media is loaded through the instance's media proxy.
- Filters:
- Timeline filters (hide boosts, replies, or show only media).
- Keyword filters to hide or warn about posts, matched against the visible text of a post.
- Organization: Lists (create, edit, manage members), followed hashtags, bookmarks, favourites, drafts.
- Discovery: Search for accounts, posts and hashtags; trending hashtags, posts and links; announcements.
- Profiles: Followers and following lists, notifications for new posts of an account, private notes, the server of remote accounts.
- Account Management: Profile editing, follow requests, muted and blocked accounts, blocked domains.
- Scheduled Posts: Pick a publication time when composing; reschedule, edit or delete scheduled posts.
- Reading position: The home timeline position and read notifications are synced with Mastodon's markers, shared with the web interface and other apps.
- Accessible: TalkBack actions on every post, counts read with their buttons, alt texts and fallbacks for media.
- Internationalized: English and German.
Some features depend on the server version: quote posts and fetching remote replies need Mastodon 4.5 or newer, decrypting push notifications needs Mastodon 4.4 or newer. On older servers Tootsie falls back to the previous behaviour (e.g. the boost button boosts directly).
🛠 Tech Stack
- Language: Kotlin 2.4
- Build: Gradle 9.7, Android Gradle Plugin 9.4, version catalog (
gradle/libs.versions.toml) - UI Framework: Jetpack Compose with Material Design 3
- Architecture: Clean Architecture (Presentation, Domain, Data) + MVVM + MVI
- Dependency Injection: Hilt
- Local Database: Room with exported schemas and migration tests
- Preferences: DataStore
- Networking: Retrofit + OkHttp
- Serialization: Kotlinx Serialization
- Push Notifications: UnifiedPush
- Image Loading: Coil
- Paging: Paging 3
- Background Tasks: WorkManager
- Testing: JUnit, MockK, kotlinx-coroutines-test, Compose UI tests, Room
MigrationTestHelper
📋 Requirements
- A recent Android Studio that supports Android Gradle Plugin 9.4
- JDK 21 to run Gradle (the app itself compiles to Java 11 bytecode)
- Android SDK 37
- Android device/emulator running Android 12 (API 31) or higher
⚙️ Setup & Installation
-
Clone the repository:
git clone https://github.com/pfitzer/tootsie.git cd tootsie -
Open in Android Studio: Import the project and let Gradle sync.
-
Build & Run: Select the
appconfiguration and run it on your device or emulator.
Build Commands (Gradle)
- Assemble Debug APK:
./gradlew assembleDebug - Assemble Release APK:
./gradlew assembleRelease - Run Unit Tests:
./gradlew testDebugUnitTest - Run Instrumented Tests (needs a connected device or emulator):
./gradlew connectedDebugAndroidTest - Run only the Room migration tests:
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.pfitzer.tootsie.data.local.TootsieDatabaseMigrationTest
Instrumented test runs uninstall the app afterwards, which deletes its data (including logins). Add -Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true to keep it installed.
CI (GitHub Actions) runs the unit tests and builds debug and release APKs; instrumented tests are not part of CI.
📂 Project Structure
app/
├── schemas/ # Exported Room schemas (one JSON per database version)
└── src/main/java/com/pfitzer/tootsie/
├── TootsieApplication.kt # Hilt application, UnifiedPush callbacks
├── MainActivity.kt # Single activity hosting Compose
├── di/ # Dependency injection modules
├── data/ # Data layer
│ ├── local/ # Room (DAOs, entities, database), DataStore, credential encryption
│ ├── remote/ # Retrofit API, DTOs, interceptors, account-scoped credentials
│ ├── repository/ # Repository implementations
│ ├── mapper/ # DTO / entity ↔ domain mappers
│ └── paging/ # Paging sources, keyword filter matching
├── domain/ # Domain layer (pure Kotlin)
│ ├── model/ # Domain models
│ ├── repository/ # Repository interfaces
│ └── usecase/ # Use cases
├── presentation/ # Presentation layer
│ ├── screens/ # Screen composables & ViewModels
│ ├── components/ # Reusable UI components
│ ├── navigation/ # Navigation graph and destinations
│ ├── theme/ # Material 3 theme
│ └── util/ # UI helpers (HTML rendering, UiText, …)
├── push/ # UnifiedPush service, Web Push decryption, notifications
├── worker/ # WorkManager workers (notification polling)
└── util/ # Cross-cutting utilities
⚠️ Critical Development Guidelines
Contributions are welcome, see CONTRIBUTING.md and the Code of Conduct.
When contributing to Tootsie, please adhere to these architectural guardrails (details in CLAUDE.md):
- DataStore Reads: Never use
.collect {}onDataStore.datafor one-time reads as it is a hot flow and will block infinitely. Always use.first(). - OkHttp Interceptors: Never use
runBlockingor suspend functions inside interceptors. Use the Provider Pattern withAtomicReferencefor thread-safe, synchronous access to tokens and the instance URL. - Initialization: Don't read DataStore in
initblocks or during Hilt construction. Every entry point that calls the API (splash screen, workers, push callbacks) must callAuthRepositoryImpl.ensureInitialized()first. - Threading: Run DataStore operations, Keystore operations and heavy IO on
Dispatchers.IO. - Strings: All user-facing strings go into
strings.xml(English and German). ViewModels expose messages asUiText, never as hardcoded strings. - Database Changes: Every entity change needs a version bump, a migration, the generated schema file in
app/schemas/and a migration test. - Credentials: Access tokens are only stored encrypted (
CredentialCipher). Requests on behalf of a specific account (push, polling) pass that account's credentials explicitly instead of using the active account.
📝 Roadmap
Planned features and known issues are tracked in GitHub Issues, for example:
- Accessibility: manual TalkBack pass and keyboard focus order (#1)
- Optional own DeepL API key for translations (#3)
- Project website with GitHub Pages (#5)
🔒 Security
Please report vulnerabilities privately, see SECURITY.md.
📄 License
This project is licensed under the MIT License - see the LICENSE file for details.