pdfcontent-scroll-under-allsystembar

 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

            )

        }

    }

}