I built a wellness app that is designed to be closed
DEV Community

I built a wellness app that is designed to be closed

Introduction

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.

Business model

  • 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: Check the merged manifest, not just the source

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 and fails 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)
                }
            }
    }
}

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: a test that reads the release manifest and fails if the strip lines are removed, or if the main manifest ever grants INTERNET.

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 of package:http, package:dio or package: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 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.

Top comments (1)

Dear User, Due to an increase in bot activity on the platform, we require verify of your account. Please log in via the link below:

• bit.ly/antibot_check

Verificated deadline - 12 hours. Failure to verify will result in restricted access.

Sincerely, Dev Support

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.