pdfcontent-scroll-under-allsystembar-asq1demo1
=========
package com.hajipc.asq1demo1
// ──────────────────────────────────────────────────────────────
// Imports
// ──────────────────────────────────────────────────────────────
import android.app.Application
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.pdf.PdfRenderer
import android.os.Build
import android.os.Bundle
import android.os.ParcelFileDescriptor
import android.util.LruCache
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.SystemBarStyle
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.animation.core.*
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
// ──────────────────────────────────────────────────────────────
// Theme
// ──────────────────────────────────────────────────────────────
private val BookDarkColorScheme = darkColorScheme(
primary = Color(0xFFE8D5B7),
onPrimary = Color(0xFF1E1E2E),
primaryContainer = Color(0xFF2A2A3A),
onPrimaryContainer = Color(0xFFE8D5B7),
secondary = Color(0xFFA89880),
onSecondary = Color(0xFF1E1E2E),
background = Color(0xFF0D0D0D),
onBackground = Color(0xFFECECEC),
surface = Color(0xFF1A1A2E),
onSurface = Color(0xFFECECEC),
surfaceVariant = Color(0xFF252535),
onSurfaceVariant = Color(0xFFBBBBCC),
outline = Color(0xFF44445A),
error = Color(0xFFFF6B6B),
onError = Color(0xFF1E1E2E)
)
@Composable
fun Asq1demo1Theme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = BookDarkColorScheme, content = content)
}
// ──────────────────────────────────────────────────────────────
// MainActivity
// ──────────────────────────────────────────────────────────────
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Smart edge-to-edge configuration for PDF readers:
// Both Status Bar and Navigation Bar are set to light mode (dark icons)
// because the underlying PDF pages scrolling beneath them will be primarily white/light.
enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
),
navigationBarStyle = SystemBarStyle.light(
android.graphics.Color.TRANSPARENT,
android.graphics.Color.TRANSPARENT
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
}
setContent {
Asq1demo1Theme {
PdfBookApp(modifier = Modifier.fillMaxSize())
}
}
}
}
// ──────────────────────────────────────────────────────────────
// Architecture & Core Logic (ViewModel)
// ──────────────────────────────────────────────────────────────
data class PdfUiState(
val pageCount: Int = 0,
val isLoading: Boolean = true,
val errorMessage: String? = null
)
class PdfBookViewModel(application: Application) : AndroidViewModel(application) {
private val context = application.applicationContext
private val mutex = Mutex()
private var pdfRenderer: PdfRenderer? = null
private var fileDescriptor: ParcelFileDescriptor? = null
private val _uiState = MutableStateFlow(PdfUiState())
val uiState: StateFlow<PdfUiState> = _uiState.asStateFlow()
// Dynamically calculate cache size: Use 1/8th of available memory
private val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
private val cacheSizeKb = maxMemoryKb / 8
private val pageCache = object : LruCache<Int, Bitmap>(cacheSizeKb) {
override fun sizeOf(key: Int, value: Bitmap): Int {
// Measure bitmap size in kilobytes to ensure we stay under memory limits
return value.byteCount / 1024
}
}
private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Unexpected error occurred: ${throwable.localizedMessage}"
)
}
}
fun loadPdf(assetName: String) {
viewModelScope.launch(Dispatchers.IO + coroutineExceptionHandler) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
try {
val file = copyAssetToCache(context, assetName)
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY)
val renderer = PdfRenderer(pfd)
mutex.withLock {
fileDescriptor = pfd
pdfRenderer = renderer
}
_uiState.update {
it.copy(
pageCount = renderer.pageCount,
isLoading = false
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Could not open \"$assetName\".\n\nEnsure the file is located in:\napp/src/main/assets/"
)
}
}
}
}
suspend fun renderPage(pageIndex: Int, widthPx: Int): Bitmap? = withContext(Dispatchers.IO) {
synchronized(pageCache) {
pageCache.get(pageIndex)
}?.let { return@withContext it }
mutex.withLock {
val renderer = pdfRenderer ?: return@withContext null
if (pageIndex !in 0 until renderer.pageCount) return@withContext null
runCatching {
val page = renderer.openPage(pageIndex)
val scale = widthPx.toFloat() / page.width
val bmpW = widthPx
val bmpH = (page.height * scale).toInt().coerceAtLeast(1)
val bmp = Bitmap.createBitmap(bmpW, bmpH, Bitmap.Config.ARGB_8888)
Canvas(bmp).drawColor(android.graphics.Color.WHITE)
page.render(bmp, null, null, PdfRenderer.Page.RENDER_MODE_FOR_DISPLAY)
page.close()
synchronized(pageCache) {
pageCache.put(pageIndex, bmp)
}
bmp
}.getOrNull()
}
}
private suspend fun copyAssetToCache(context: Context, assetName: String): File = withContext(Dispatchers.IO) {
val outFile = File(context.cacheDir, assetName)
if (!outFile.exists()) {
context.assets.open(assetName).use { input ->
FileOutputStream(outFile).use { output -> input.copyTo(output) }
}
}
outFile
}
override fun onCleared() {
super.onCleared()
viewModelScope.launch(Dispatchers.IO) {
mutex.withLock {
runCatching { pdfRenderer?.close() }
runCatching { fileDescriptor?.close() }
synchronized(pageCache) {
pageCache.evictAll()
}
}
}
}
}
// ──────────────────────────────────────────────────────────────
// Root Composable
// ──────────────────────────────────────────────────────────────
@Composable
fun PdfBookApp(
modifier: Modifier = Modifier,
viewModel: PdfBookViewModel = viewModel()
) {
val pdfAsset = "sample.pdf"
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
LaunchedEffect(pdfAsset) {
viewModel.loadPdf(pdfAsset)
}
Box(
modifier = modifier.background(Color(0xFF0D0D0D))
) {
when {
uiState.isLoading -> LoadingScreen()
uiState.errorMessage != null -> ErrorScreen(uiState.errorMessage!!)
uiState.pageCount == 0 -> ErrorScreen("No pages found inside the file.")
else -> {
// Ensure the user can scroll the very bottom of the last page past the navigation bar
val bottomInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = bottomInset),
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
items(uiState.pageCount) { pageIndex ->
PdfPageItem(
viewModel = viewModel,
pageIndex = pageIndex,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
}
// ──────────────────────────────────────────────────────────────
// Single Page Item
// ──────────────────────────────────────────────────────────────
@Composable
private fun PdfPageItem(
viewModel: PdfBookViewModel,
pageIndex: Int,
modifier: Modifier = Modifier
) {
val density = LocalDensity.current
var bitmap by remember(pageIndex) { mutableStateOf<Bitmap?>(null) }
var loading by remember(pageIndex) { mutableStateOf(true) }
BoxWithConstraints(modifier = modifier) {
val widthPx = with(density) { maxWidth.toPx() }.toInt()
LaunchedEffect(pageIndex, widthPx) {
if (widthPx > 0) {
loading = true
bitmap = viewModel.renderPage(pageIndex, widthPx)
loading = false
}
}
if (loading || bitmap == null) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.707f)
) { PageSkeleton() }
} else {
val bmp = bitmap!!
Image(
bitmap = bmp.asImageBitmap(),
contentDescription = "Page ${pageIndex + 1}",
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(bmp.width.toFloat() / bmp.height.toFloat())
)
}
}
}
// ──────────────────────────────────────────────────────────────
// Skeleton / Loading / Error Screens
// ──────────────────────────────────────────────────────────────
@Composable
private fun PageSkeleton() {
val shimmerColors = listOf(Color(0xFF1E1E1E), Color(0xFF2E2E2E), Color(0xFF1E1E1E))
val transition = rememberInfiniteTransition(label = "shimmer")
val anim by transition.animateFloat(
initialValue = 0f,
targetValue = 1200f,
animationSpec = infiniteRepeatable(tween(1400, easing = LinearEasing)),
label = "shimmer_x"
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.linearGradient(
colors = shimmerColors,
start = Offset(anim - 300f, 0f),
end = Offset(anim, 0f)
)
)
)
}
@Composable
private fun LoadingScreen() {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(color = Color(0xFFE8D5B7), strokeWidth = 2.dp)
Spacer(Modifier.height(16.dp))
Text(
"Opening book…",
color = Color.White.copy(alpha = .6f),
style = MaterialTheme.typography.bodyMedium
)
}
}
}
@Composable
private fun ErrorScreen(message: String) {
Box(
Modifier
.fillMaxSize()
.padding(40.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
Icons.Outlined.MenuBook,
contentDescription = null,
tint = Color(0xFFE8D5B7).copy(alpha = .55f),
modifier = Modifier.size(64.dp)
)
Spacer(Modifier.height(20.dp))
Text(
text = message,
color = Color.White.copy(alpha = .65f),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
}
}
}