Notifications, Errors & Offline
Order notification
Step 1: channel and permission
Since Android 8 every notification belongs to a channel the user can switch off in settings. We create the "Order updates" channel in KartApp.onCreate(). Since Android 13 the app must also ask for POST_NOTIFICATIONS; if the user says no, the app works normally without notifications.
package com.codingeasily.karteasy.notify
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.navigation.NavDeepLinkBuilder
import com.codingeasily.karteasy.MainActivity
import com.codingeasily.karteasy.R
import com.codingeasily.karteasy.ui.orders.OrderDetailFragment
/** Local notifications, e.g. "Order placed". Tapping it opens that order's detail screen. */
object Notifier {
private const val CHANNEL_ORDERS = "orders"
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ORDERS,
context.getString(R.string.channel_orders),
NotificationManager.IMPORTANCE_DEFAULT
).apply { description = context.getString(R.string.channel_orders_desc) }
context.getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
}
fun orderPlaced(context: Context, orderId: Int, orderNo: String, total: String) {
// Android 13+: without the permission we simply skip the notification.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) return
// Deep link straight into the Navigation graph: MainActivity -> orderDetailFragment
val pendingIntent = NavDeepLinkBuilder(context)
.setComponentName(MainActivity::class.java)
.setGraph(R.navigation.nav_main)
.setDestination(R.id.orderDetailFragment)
.setArguments(bundleOf(OrderDetailFragment.ARG_ORDER_ID to orderId))
.createPendingIntent()
val notification = NotificationCompat.Builder(context, CHANNEL_ORDERS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(context.getString(R.string.notif_order_title))
.setContentText(context.getString(R.string.notif_order_text, orderNo, total))
.setStyle(NotificationCompat.BigTextStyle().bigText(context.getString(R.string.notif_order_text, orderNo, total)))
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.setColor(ContextCompat.getColor(context, R.color.brand))
.build()
NotificationManagerCompat.from(context).notify(orderId, notification)
}
}// MainActivity.kt – ask once on Android 13+ (the result is not required: the app works without it)
private val askNotifications = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
private fun askNotificationPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
askNotifications.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M7,18c-1.1,0 -1.99,0.9 -1.99,2S5.9,22 7,22s2,-0.9 2,-2 -0.9,-2 -2,-2zM1,2v2h2l3.6,7.59 -1.35,2.45c-0.16,0.28 -0.25,0.61 -0.25,0.96 0,1.1 0.9,2 2,2h12v-2H7.42c-0.14,0 -0.25,-0.11 -0.25,-0.25l0.03,-0.12 0.9,-1.63h7.45c0.75,0 1.41,-0.41 1.75,-1.03l3.58,-6.49C21.25,4.16 20.78,3.5 20.1,3.5H5.21l-0.94,-2H1zM17,18c-1.1,0 -1.99,0.9 -1.99,2s0.89,2 1.99,2 2,-0.9 2,-2 -0.9,-2 -2,-2z" />
</vector>Step 2: tap opens the order
NavDeepLinkBuilder creates a PendingIntent that starts MainActivity and navigates straight to orderDetailFragment with the order id. It also builds a sensible back stack (Home underneath), so Back from the order goes to Home.
// CheckoutFragment.kt – right after the API confirms the order
is CheckoutViewModel.Event.OrderPlaced -> {
val total = Format.price(e.order.total)
Notifier.orderPlaced(requireContext(), e.order.orderId, e.order.orderNo, total)
findNavController().navigate(R.id.action_checkout_to_success, bundleOf(/* … */))
}Error handling everywhere
Every screen follows the same three rules, so errors never crash the app and the user always knows what to do:
| Situation | User sees |
|---|---|
| Home fails to load | cloud icon, "No internet connection…", Try again |
| Out of stock while adding | Snackbar "Only 3 left in stock." |
| Wrong coupon | red text under the coupon field |
| Token expired | login screen opens |
| Next page fails while scrolling | Snackbar; the loaded items stay |
Offline: banner + cache
Step 3: NetworkMonitor
A ConnectivityManager.NetworkCallback updates a StateFlow<Boolean> whenever the connection appears or disappears. MainActivity shows the banner; the OkHttp interceptor uses isOnline().
package com.codingeasily.karteasy.util
import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Tells the app whether the phone is online, and updates live when that changes. */
class NetworkMonitor(context: Context) {
private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val _online = MutableStateFlow(isOnline())
val online: StateFlow<Boolean> = _online.asStateFlow()
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
_online.value = true
}
override fun onLost(network: Network) {
_online.value = isOnline()
}
}
init {
val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.build()
cm.registerNetworkCallback(request, callback)
}
fun isOnline(): Boolean {
val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
}
}// MainActivity.kt – the grey "You are offline" strip under the toolbar
launch {
app.network.online.collect { online -> binding.offlineBanner.visibleIf(!online) }
}Step 4: OkHttp offline cache
OkHttp has a 10 MB disk cache. The trick is to always use the network when online (so the cart and prices are never stale) but still store a copy, and use the copy only when offline:
// ApiClient.kt (offline part)
// Online : always ask the server (FORCE_NETWORK) so prices, stock and the cart are fresh,
// but OkHttp still saves a copy of the response on disk.
// Offline: answer GET requests from that saved copy (up to 7 days old).
val offline = Interceptor { chain ->
val request = chain.request()
if (request.method != "GET") return@Interceptor chain.proceed(request)
val policy = if (network.isOnline()) CacheControl.FORCE_NETWORK
else CacheControl.Builder().onlyIfCached().maxStale(7, TimeUnit.DAYS).build()
chain.proceed(request.newBuilder().cacheControl(policy).build())
}
// Our PHP API sends no cache headers. This network interceptor marks GET responses
// as storable ("public" is needed because the request has an Authorization header).
val cacheResponses = Interceptor { chain ->
val response = chain.proceed(chain.request())
if (chain.request().method == "GET" && response.isSuccessful) {
response.newBuilder().header("Cache-Control", "public, max-age=0").removeHeader("Pragma").build()
} else response
}Online
- FORCE_NETWORK
- fresh answer from PHP
- copy saved to disk
Offline
- onlyIfCached + maxStale 7 days
- answer from disk
- never cached → error with Retry
Test checklist
- 1Place an order → notification "Order placed 🎉" → tap → the order opens
- 2Deny the notification permission → ordering still works, just no notification
- 3Open Home, then airplane mode → banner appears, pull to refresh still shows Home
- 4Open a product you never opened while offline → error state with Try again
- 5Turn internet back on → banner disappears, Try again loads
- 6Delete the token row in phpMyAdmin → next app start opens Login
