Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.go-mizu.dev/llms.txt

Use this file to discover all available pages before exploring further.

Build native Android applications with Kotlin and Jetpack Compose that seamlessly integrate with your Mizu backend. The Android template provides a complete project structure with modern architecture patterns.

Quick Start

mizu new ./my-android-app --template mobile/android
This creates a complete Android project with:
  • Kotlin + Jetpack Compose UI
  • Coroutines and Flow for async
  • Retrofit for networking
  • Hilt for dependency injection
  • Room for local storage

Project Structure

my-android-app/
├── backend/                    # Mizu Go backend
│   ├── cmd/server/
│   ├── app/server/
│   ├── go.mod
│   └── Makefile
│
├── android/                    # Android app
│   ├── app/
│   │   ├── src/main/
│   │   │   ├── java/com/example/app/
│   │   │   │   ├── MainActivity.kt
│   │   │   │   ├── MyApplication.kt
│   │   │   │   ├── di/              # Dependency injection
│   │   │   │   ├── data/            # Data layer
│   │   │   │   │   ├── api/
│   │   │   │   │   ├── local/
│   │   │   │   │   └── repository/
│   │   │   │   ├── domain/          # Business logic
│   │   │   │   ├── ui/              # UI layer
│   │   │   │   │   ├── theme/
│   │   │   │   │   ├── navigation/
│   │   │   │   │   └── screens/
│   │   │   │   └── util/
│   │   │   ├── res/
│   │   │   └── AndroidManifest.xml
│   │   └── build.gradle.kts
│   ├── gradle/
│   ├── build.gradle.kts
│   └── settings.gradle.kts
│
└── Makefile

Template Options

mizu new ./my-app --template mobile/android \
  --var name=MyApp \
  --var package=com.example.myapp \
  --var minSdk=26 \
  --var ui=compose
VariableDescriptionDefault
nameProject nameDirectory name
packagePackage namecom.example.{{name}}
minSdkMinimum SDK version26
uiUI framework: compose, views, hybridcompose

API Client

// data/api/ApiService.kt
interface ApiService {
    @GET("api/users")
    suspend fun getUsers(
        @Query("page") page: Int = 1,
        @Query("per_page") perPage: Int = 20
    ): PageResponse<User>

    @GET("api/users/{id}")
    suspend fun getUser(@Path("id") id: String): User

    @POST("api/auth/login")
    suspend fun login(@Body request: LoginRequest): AuthResponse

    @POST("api/push/register")
    suspend fun registerPushToken(@Body request: PushTokenRequest)

    @GET("api/sync")
    suspend fun sync(@Query("sync_token") token: String?): SyncResponse<Item>
}

// data/api/MizuHeaderInterceptor.kt
class MizuHeaderInterceptor(
    private val context: Context,
    private val deviceIdProvider: DeviceIdProvider
) : Interceptor {

    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request().newBuilder()
            .header("X-Device-ID", deviceIdProvider.getDeviceId())
            .header("X-App-Version", BuildConfig.VERSION_NAME)
            .header("X-App-Build", BuildConfig.VERSION_CODE.toString())
            .header("X-Device-Model", Build.MODEL)
            .header("X-Platform", "android")
            .header("X-OS-Version", Build.VERSION.RELEASE)
            .header("X-Timezone", TimeZone.getDefault().id)
            .header("X-Locale", Locale.getDefault().toLanguageTag())
            .header("X-API-Version", "v2")
            .build()

        val response = chain.proceed(request)

        // Check for deprecation warning
        if (response.header("X-API-Deprecated") == "true") {
            EventBus.post(ApiDeprecatedEvent())
        }

        return response
    }
}

Dependency Injection

// di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(
        authInterceptor: AuthInterceptor,
        mizuHeaderInterceptor: MizuHeaderInterceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(mizuHeaderInterceptor)
            .addInterceptor(authInterceptor)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = if (BuildConfig.DEBUG)
                    HttpLoggingInterceptor.Level.BODY
                else
                    HttpLoggingInterceptor.Level.NONE
            })
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()
    }

    @Provides
    @Singleton
    fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BuildConfig.API_BASE_URL)
            .client(okHttpClient)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
}

Authentication

// data/repository/AuthRepository.kt
class AuthRepository @Inject constructor(
    private val api: ApiService,
    private val tokenManager: TokenManager
) {
    suspend fun login(email: String, password: String): Result<User> {
        return try {
            val response = api.login(LoginRequest(email, password))
            tokenManager.saveTokens(response.accessToken, response.refreshToken)
            Result.success(response.user)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }

    suspend fun logout() {
        tokenManager.clearTokens()
    }

    fun isAuthenticated(): Boolean = tokenManager.hasValidToken()
}

// data/local/TokenManager.kt
class TokenManager @Inject constructor(
    private val encryptedPrefs: SharedPreferences
) {
    fun saveTokens(accessToken: String, refreshToken: String) {
        encryptedPrefs.edit()
            .putString("access_token", accessToken)
            .putString("refresh_token", refreshToken)
            .apply()
    }

    fun getAccessToken(): String? = encryptedPrefs.getString("access_token", null)

    fun hasValidToken(): Boolean = getAccessToken() != null

    fun clearTokens() {
        encryptedPrefs.edit().clear().apply()
    }
}

Compose UI

// ui/navigation/NavGraph.kt
@Composable
fun NavGraph(
    navController: NavHostController = rememberNavController(),
    startDestination: String = Screen.Home.route
) {
    NavHost(navController = navController, startDestination = startDestination) {
        composable(Screen.Home.route) {
            HomeScreen(
                onNavigateToDetail = { id ->
                    navController.navigate(Screen.Detail.createRoute(id))
                }
            )
        }

        composable(
            route = Screen.Detail.route,
            arguments = listOf(navArgument("id") { type = NavType.StringType })
        ) { backStackEntry ->
            DetailScreen(
                id = backStackEntry.arguments?.getString("id") ?: "",
                onBack = { navController.popBackStack() }
            )
        }

        composable(Screen.Profile.route) {
            ProfileScreen()
        }
    }
}

sealed class Screen(val route: String) {
    object Home : Screen("home")
    object Profile : Screen("profile")
    object Detail : Screen("detail/{id}") {
        fun createRoute(id: String) = "detail/$id"
    }
}

ViewModel

// ui/screens/home/HomeViewModel.kt
@HiltViewModel
class HomeViewModel @Inject constructor(
    private val repository: ItemRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<HomeUiState>(HomeUiState.Loading)
    val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()

    init {
        loadItems()
    }

    fun loadItems() {
        viewModelScope.launch {
            _uiState.value = HomeUiState.Loading

            repository.getItems()
                .onSuccess { items ->
                    _uiState.value = HomeUiState.Success(items)
                }
                .onFailure { error ->
                    _uiState.value = HomeUiState.Error(error.message ?: "Unknown error")
                }
        }
    }
}

sealed interface HomeUiState {
    object Loading : HomeUiState
    data class Success(val items: List<Item>) : HomeUiState
    data class Error(val message: String) : HomeUiState
}

Screen

// ui/screens/home/HomeScreen.kt
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    onNavigateToDetail: (String) -> Unit
) {
    val uiState by viewModel.uiState.collectAsState()

    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Home") })
        }
    ) { padding ->
        when (val state = uiState) {
            is HomeUiState.Loading -> {
                Box(
                    modifier = Modifier.fillMaxSize(),
                    contentAlignment = Alignment.Center
                ) {
                    CircularProgressIndicator()
                }
            }

            is HomeUiState.Error -> {
                ErrorContent(
                    message = state.message,
                    onRetry = { viewModel.loadItems() },
                    modifier = Modifier.padding(padding)
                )
            }

            is HomeUiState.Success -> {
                LazyColumn(
                    modifier = Modifier.padding(padding),
                    contentPadding = PaddingValues(16.dp)
                ) {
                    items(state.items) { item ->
                        ItemCard(
                            item = item,
                            onClick = { onNavigateToDetail(item.id) }
                        )
                    }
                }
            }
        }
    }
}

Push Notifications

// data/push/MyFirebaseMessagingService.kt
class MyFirebaseMessagingService : FirebaseMessagingService() {

    @Inject
    lateinit var pushRepository: PushRepository

    override fun onCreate() {
        super.onCreate()
        // Inject dependencies
        EntryPointAccessors.fromApplication(
            applicationContext,
            PushServiceEntryPoint::class.java
        ).inject(this)
    }

    override fun onNewToken(token: String) {
        CoroutineScope(Dispatchers.IO).launch {
            pushRepository.registerToken(token)
        }
    }

    override fun onMessageReceived(message: RemoteMessage) {
        // Handle notification
        message.notification?.let { notification ->
            showNotification(notification.title, notification.body)
        }

        // Handle data payload
        message.data.let { data ->
            handlePushData(data)
        }
    }

    private fun showNotification(title: String?, body: String?) {
        val channelId = "default"
        val notificationManager = getSystemService(NotificationManager::class.java)

        val notification = NotificationCompat.Builder(this, channelId)
            .setContentTitle(title)
            .setContentText(body)
            .setSmallIcon(R.drawable.ic_notification)
            .setAutoCancel(true)
            .build()

        notificationManager.notify(System.currentTimeMillis().toInt(), notification)
    }
}
// AndroidManifest.xml
<activity android:name=".MainActivity">
    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data android:scheme="https" android:host="example.com" />
    </intent-filter>
</activity>

// MainActivity.kt
class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            MyAppTheme {
                val navController = rememberNavController()

                LaunchedEffect(Unit) {
                    handleIntent(intent, navController)
                }

                NavGraph(navController = navController)
            }
        }
    }

    override fun onNewIntent(intent: Intent) {
        super.onNewIntent(intent)
        // Handle deep link from new intent
    }

    private fun handleIntent(intent: Intent, navController: NavController) {
        intent.data?.let { uri ->
            when {
                uri.path?.startsWith("/share/") == true -> {
                    val id = uri.path?.removePrefix("/share/") ?: return
                    navController.navigate(Screen.Detail.createRoute(id))
                }
                uri.path?.startsWith("/profile/") == true -> {
                    navController.navigate(Screen.Profile.route)
                }
            }
        }
    }
}

Offline Support

// data/repository/SyncRepository.kt
class SyncRepository @Inject constructor(
    private val api: ApiService,
    private val database: AppDatabase,
    private val prefs: SharedPreferences
) {
    private val syncTokenKey = "sync_token"

    suspend fun sync(): Result<Unit> {
        return try {
            val token = prefs.getString(syncTokenKey, null)
            val response = api.sync(token)

            // Apply changes in transaction
            database.withTransaction {
                response.created.forEach { database.itemDao().insert(it) }
                response.updated.forEach { database.itemDao().update(it) }
                response.deleted.forEach { database.itemDao().deleteById(it) }
            }

            // Save new token
            prefs.edit().putString(syncTokenKey, response.syncToken).apply()

            Result.success(Unit)
        } catch (e: Exception) {
            Result.failure(e)
        }
    }
}

Running the Project

Start Backend

cd backend
make run

Run Android App

  1. Open android/ folder in Android Studio
  2. Sync Gradle files
  3. Select device or emulator
  4. Click Run

Build Configuration

// app/build.gradle.kts
android {
    buildTypes {
        debug {
            buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:3000\"")
        }
        release {
            buildConfigField("String", "API_BASE_URL", "\"https://api.example.com\"")
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
        }
    }
}

Best Practices

Error Handling

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val exception: ApiException) : ApiResult<Nothing>()
}

suspend fun <T> safeApiCall(call: suspend () -> T): ApiResult<T> {
    return try {
        ApiResult.Success(call())
    } catch (e: HttpException) {
        val error = parseError(e.response())
        when (error.code) {
            "unauthorized" -> ApiResult.Error(UnauthorizedException())
            "upgrade_required" -> ApiResult.Error(UpgradeRequiredException(error.details))
            else -> ApiResult.Error(ServerException(error.message))
        }
    } catch (e: Exception) {
        ApiResult.Error(NetworkException(e.message))
    }
}

Testing

@HiltAndroidTest
class HomeViewModelTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @Inject
    lateinit var repository: FakeItemRepository

    private lateinit var viewModel: HomeViewModel

    @Before
    fun setup() {
        hiltRule.inject()
        viewModel = HomeViewModel(repository)
    }

    @Test
    fun `loadItems success updates state`() = runTest {
        repository.setItems(listOf(testItem))

        viewModel.loadItems()

        val state = viewModel.uiState.first()
        assertThat(state).isInstanceOf(HomeUiState.Success::class.java)
    }
}

Next Steps

iOS

Build native iOS apps

Flutter

Cross-platform with Flutter

Push Notifications

FCM integration

Deep Links

App Links setup