Most wellness apps earn more the more often you open them. That one fact explains a lot of their design: the streak you are afraid to break, the 9 pm notification, the red badge, the "you missed yesterday" screen.
I wanted the opposite. An app that helps for a minute and then has no reason to pull you back. So I built AsanaPals, an offline Android app where five animal companions help you move, rest, energize, get unstuck or breathe. No account, no scores, no streaks, no reminders.
This post is about the engineering side: how you make "it never phones home" a property of the build instead of a promise in a privacy policy.
The business model came first
"No streaks" is easy to write and hard to keep if your revenue depends on daily active users. So the model decision came before the rest:
- Paid once on Google Play. No subscription, no ads, nothing sold inside the app. All five companions and every moment are included.
- If the app earns nothing from your time, there is no incentive to manufacture reasons for you to come back.
Every technical decision below follows from that.
Rule 1: the release build has no internet permission
On Android, an app without the INTERNET permission cannot open a socket. Not "chooses not to": cannot. That is a much stronger guarantee than careful code, because it also covers every dependency you pull in.
The catch: plugins can bring the permission with them. video_player and url_launcher both declare network permissions for paths I do not use. So the release manifest strips them from the merged result:
<!-- android/app/src/release/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" tools:node="remove" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" tools:node="remove" />
<uses-permission android:name="android.permission.WAKE_LOCK" tools:node="remove" />
WAKE_LOCK goes too. ExoPlayer declares it for background playback, and nothing in the app plays in the background, so it is stripped rather than shipped unused.
Debug and profile builds keep INTERNET, because Flutter's tooling attaches to the Dart VM over the network during development. Only release is locked.
Rule 2: no release can be built with the permission in it
A tools:node="remove" line protects you until a dependency does something unexpected in the merge. So a Gradle task reads the merged release manifest, the one that actually ships, and fails the build if INTERNET survived:
// android/app/build.gradle.kts
abstract class VerifyNoInternetPermissionTask : DefaultTask() {
@get:InputFile
abstract val mergedManifest: RegularFileProperty
@TaskAction
fun verify() {
val manifest = mergedManifest.get().asFile.readText()
val declaresInternet = Regex(
"""<uses-permission\b[^>]*android:name\s*=\s*"android\.permission\.INTERNET"""",
).containsMatchIn(manifest)
if (declaresInternet) {
throw GradleException(
"Release merged manifest declares the INTERNET permission, which breaks the " +
"true-offline guarantee. Adding network capability must be a deliberate, " +
"recorded decision.",
)
}
}
}
androidComponents {
onVariants { variant ->
if (variant.name == "release") {
val verifyTask = tasks.register(
"verifyReleaseNoInternetPermission",
VerifyNoInternetPermissionTask::class.java,
) {
mergedManifest.set(variant.artifacts.get(SingleArtifact.MERGED_MANIFEST))
}
tasks.named("check").configure { dependsOn(verifyTask) }
// Every release build runs it, not only `check`: the App Bundle
// and the APK, however they are built.
tasks.matching { it.name == "bundleRelease" || it.name == "assembleRelease" }
.configureEach { dependsOn(verifyTask) }
}
}
}
The App Bundle and the APK both depend on the check, so it runs whether the release comes from my release script, a plain flutter build, or the IDE. I tested it the only way that counts: I put INTERNET back into the release manifest and ran a release build. It failed with the message above.
The error message is deliberate. Whoever hits it next (probably me, in six months) learns that network access is a product decision, not a dependency bump.
There is also a source-level half that runs with every flutter test: tests that fail if the strip lines are removed, if the main manifest ever grants INTERNET, or if the check is ever unhooked from the release builds.
Rule 3: tests that ban the wrong imports and the wrong words
Some promises are about code, some are about copy. Both are tested in plain Dart:
-
No network clients. A guard test scans
lib/and fails on any import ofpackage:http,package:dioorpackage:google_fonts. Fonts, video, audio and art are bundled instead. - Nothing for sale inside. A pricing guard fails on billing code and on trial, premium, upgrade or subscription copy.
- No pressure language. A copy guard fails on streaks, XP, levels, scores, badges, unlocks and "you missed" in UI strings. Rejecting them ("no streaks") is allowed.
The last one sounds odd, but pressure language creeps back in through small, friendly-sounding strings, and a test catches it before a user does.
Rule 4: store less than you could
Everything the app keeps (your name, your chosen companion, settings and a short list of moments in plain words) lives in app-private storage on the phone. No runtime permissions at all: no camera, microphone, location, storage or notifications.
One detail I like: Luna, the calm companion, asks three gentle questions, and you answer by tapping a soft word. Your answer is never stored. Only the fact that a quiet moment happened is kept. The app does not need to know how you felt to be useful, so it does not ask to remember.
Text-to-speech works the same way. Babu can read a stretch's steps aloud through the phone's on-device voice, via a small Kotlin platform channel instead of a speech plugin, so there is no cloud voice anywhere in the path.
What I got wrong
My safety check did not run on real builds. The Gradle check above existed for a while hooked only to check, a task my release script never ran. So a release could have shipped with INTERNET if a library brought it back in, and the guard would have stayed silent. Now the release builds themselves depend on it, and a test fails if anyone unhooks it again. Lesson: a guard is only real if it sits on the path that actually ships.
My first plan had subscription tiers and a streak reward. Monthly and annual options, and a special variant you could unlock by keeping a streak going. Writing down the actual principle ("the app should never need your attention") made both impossible to defend. They went.
I planned reminders, then dropped them. The early design had opt-in break reminders, off by default. Even opt-in, they meant a notification permission and an app that taps you on the shoulder. They were cut before release, so the app now sends no notifications at all. If you want a moment, you open it.
I had animated companions, then removed them. They added weight and complexity for a small gain. Painted scenes and short looping clips carried the character just as well.
Progress without numbers is harder than it sounds. Removing streaks leaves a real question: how does someone feel they are getting somewhere? In AsanaPals, it is a season that only grows and a small garden that keeps growing while you are away and never wilts. Nothing resets, and nothing is lost if you skip a week.
An honest note on assets
The companion art, video and audio were made with AI tools (ChatGPT, Gemini, Lyria and Flow). The app says so in its settings and on the website's credits page. I would rather tell you than have you wonder.
Where it is now
AsanaPals is coming soon to Google Play. The first 100 people who join the waitlist get the pre-release version free through Play testing.
https://asanapals.space/?utm_source=devto&utm_medium=article&utm_campaign=launch
I would like to hear from other developers: have you shipped an app with no internet permission, and what broke first? And if you have opinions on progress without streaks, I am all ears.









