Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import com.itsaky.androidide.compose.preview.databinding.FragmentComposePreviewB
import com.itsaky.androidide.compose.preview.runtime.ComposeClassLoader
import com.itsaky.androidide.compose.preview.runtime.ComposableRenderer
import com.itsaky.androidide.resources.R as ResourcesR
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory

class ComposePreviewFragment : Fragment() {
Expand Down Expand Up @@ -48,11 +50,10 @@ class ComposePreviewFragment : Fragment() {
observeState()

val filePath = arguments?.getString(ARG_FILE_PATH) ?: ""
viewModel.initialize(requireContext(), filePath)

arguments?.getString(ARG_SOURCE_CODE)?.let {
sourceCode = it
}
viewModel.initialize(requireContext(), filePath, sourceCode)
}

private fun setupToolbar() {
Expand All @@ -63,7 +64,7 @@ class ComposePreviewFragment : Fragment() {

private fun setupPreview() {
classLoader = ComposeClassLoader(requireContext())
renderer = ComposableRenderer(binding.composePreview, classLoader!!)
renderer = ComposableRenderer(binding.composePreview)
}

private fun observeState() {
Expand Down Expand Up @@ -110,14 +111,15 @@ class ComposePreviewFragment : Fragment() {
is PreviewState.Ready -> {
val loader = classLoader ?: return
val render = renderer ?: return
loader.setProjectDexFiles(state.projectDexFiles)
loader.setRuntimeDex(state.runtimeDex)
val config = state.previewConfigs.firstOrNull() ?: return
render.render(
dexFile = state.dexFile,
className = state.className,
functionName = config.functionName
)
viewLifecycleOwner.lifecycleScope.launch {
val clazz = withContext(Dispatchers.IO) {
loader.setProjectDexFiles(state.projectDexFiles)
loader.setRuntimeDex(state.runtimeDex)
loader.loadClass(state.dexFile, state.className)
} ?: return@launch
render.render(clazz, config.functionName, null, null)
}
}
is PreviewState.Error -> {
showError(state)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.itsaky.androidide.compose.preview.data.repository.ComposePreviewRepos
import com.itsaky.androidide.compose.preview.data.repository.InitializationResult
import com.itsaky.androidide.compose.preview.domain.PreviewSourceParser
import com.itsaky.androidide.compose.preview.domain.model.ParsedPreviewSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
Expand All @@ -20,6 +21,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
Expand All @@ -35,7 +37,8 @@ sealed class PreviewState {
val className: String,
val previewConfigs: List<PreviewConfig>,
val runtimeDex: File?,
val projectDexFiles: List<File> = emptyList()
val projectDexFiles: List<File> = emptyList(),
val resourceApk: File? = null
) : PreviewState()
data class Error(
val message: String,
Expand All @@ -48,8 +51,19 @@ enum class DisplayMode { ALL, SINGLE }

data class PreviewConfig(
val functionName: String,
val key: String,
val displayName: String,
val group: String? = null,
val widthDp: Int? = null,
val heightDp: Int? = null,
val widthDp: Int? = null
val showBackground: Boolean = false,
val backgroundColor: Long? = null,
val uiMode: Int? = null,
val fontScale: Float? = null,
val locale: String? = null,
val parameterProvider: String? = null,
val parameterLimit: Int = Int.MAX_VALUE,
val parameterIndex: Int = 0
)

@OptIn(FlowPreview::class)
Expand All @@ -70,36 +84,36 @@ class ComposePreviewViewModel(
private val _availablePreviews = MutableStateFlow<List<String>>(emptyList())
val availablePreviews: StateFlow<List<String>> = _availablePreviews.asStateFlow()

private val sourceChanges = MutableSharedFlow<SourceUpdate>()
private val sourceChanges = MutableSharedFlow<String>()

private var currentSource: String = ""
private var cachedFilePath: String = ""
private var modulePath: String? = null
private var variantName: String = "debug"
private var resourceApk: File? = null
private val isInitialized = AtomicBoolean(false)
private var initializationDeferred = kotlinx.coroutines.CompletableDeferred<Unit>()
private val initMutex = Mutex()

private data class SourceUpdate(
val source: String,
val parsedSource: ParsedPreviewSource
)

init {
viewModelScope.launch {
sourceChanges
.debounce(DEBOUNCE_MS)
.distinctUntilChanged { old, new -> old.source == new.source }
.collect { update ->
compilePreview(update.source, update.parsedSource)
.distinctUntilChanged()
.collect { source ->
val parsed = withContext(Dispatchers.Default) { parseAndValidateSource(source) }
if (parsed != null) {
compilePreview(source, parsed)
}
}
}
}

fun initialize(context: Context, filePath: String) {
fun initialize(context: Context, filePath: String, source: String) {
if (!isInitialized.compareAndSet(false, true)) return

cachedFilePath = filePath
currentSource = source

viewModelScope.launch {
_previewState.value = PreviewState.Initializing
Expand All @@ -110,10 +124,15 @@ class ComposePreviewViewModel(
is InitializationResult.Ready -> {
modulePath = result.projectContext.modulePath
variantName = result.projectContext.variantName
resourceApk = result.projectContext.resourceApk
initializationDeferred.complete(Unit)
_previewState.value = PreviewState.Idle
LOG.info("ViewModel initialized, modulePath={}, variant={}",
modulePath, variantName)
if (currentSource.isNotBlank()) {
compileNow(currentSource)
} else {
_previewState.value = PreviewState.Idle
}
}
is InitializationResult.NeedsBuild -> {
modulePath = result.modulePath
Expand Down Expand Up @@ -144,18 +163,15 @@ class ComposePreviewViewModel(

fun onSourceChanged(source: String) {
currentSource = source
val parsed = parseAndValidateSource(source) ?: return

viewModelScope.launch {
sourceChanges.emit(SourceUpdate(source, parsed))
sourceChanges.emit(source)
}
}

fun compileNow(source: String) {
currentSource = source
val parsed = parseAndValidateSource(source) ?: return

viewModelScope.launch {
val parsed = withContext(Dispatchers.Default) { parseAndValidateSource(source) } ?: return@launch
compilePreview(source, parsed)
}
}
Expand All @@ -168,11 +184,13 @@ class ComposePreviewViewModel(

val parsed = sourceParser.parse(source)
if (parsed == null) {
LOG.warn("parse: rejected - missing package declaration (sourceLen={})", source.length)
_previewState.value = PreviewState.Error("Missing package declaration in source")
return null
}

if (parsed.previewConfigs.isEmpty()) {
LOG.warn("parse: no @Preview functions found in package {}", parsed.packageName)
_previewState.value = PreviewState.Empty
return null
}
Expand All @@ -182,10 +200,10 @@ class ComposePreviewViewModel(
}

private fun updateAvailablePreviews(configs: List<PreviewConfig>) {
val functionNames = configs.map { it.functionName }
_availablePreviews.value = functionNames
if (_selectedPreview.value == null || !functionNames.contains(_selectedPreview.value)) {
_selectedPreview.value = functionNames.first()
val names = configs.map { it.displayName }
_availablePreviews.value = names
if (_selectedPreview.value == null || !names.contains(_selectedPreview.value)) {
_selectedPreview.value = names.firstOrNull()
}
}

Expand All @@ -211,11 +229,13 @@ class ComposePreviewViewModel(
className = result.className,
previewConfigs = parsed.previewConfigs,
runtimeDex = result.runtimeDex,
projectDexFiles = result.projectDexFiles
projectDexFiles = result.projectDexFiles,
resourceApk = resourceApk
)
}
.onFailure { error ->
val diagnostics = if (error is CompilationException) error.diagnostics else emptyList()
LOG.error("compile: FAILED - {} ({} diagnostic(s))", error.message, diagnostics.size)
_previewState.value = PreviewState.Error(
message = error.message ?: "Compilation failed",
diagnostics = diagnostics
Expand Down Expand Up @@ -269,6 +289,7 @@ class ComposePreviewViewModel(
is InitializationResult.Ready -> {
modulePath = result.projectContext.modulePath
variantName = result.projectContext.variantName
resourceApk = result.projectContext.resourceApk
isInitialized.set(true)
initializationDeferred.complete(Unit)
LOG.debug("refreshAfterBuild: initialization complete, state=Ready")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,11 @@ class CompilerDaemon(
idleTimeoutJob?.cancel()
idleTimeoutJob = timeoutScope.launch {
delay(IDLE_TIMEOUT_MS)
if (daemonProcess?.isAlive == true) {
LOG.info("Stopping idle compiler daemon after {}ms", IDLE_TIMEOUT_MS)
stopDaemon()
mutex.withLock {
if (daemonProcess?.isAlive == true) {
LOG.info("Stopping idle compiler daemon after {}ms", IDLE_TIMEOUT_MS)
stopDaemon()
}
}
}
}
Expand All @@ -295,27 +297,38 @@ class CompilerDaemon(
idleTimeoutJob?.cancel()
idleTimeoutJob = null

try {
processWriter?.write("EXIT\n")
processWriter?.flush()
daemonProcess?.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e: Exception) {
LOG.debug("Error sending EXIT to daemon", e)
}
val process = daemonProcess
val writer = processWriter
val reader = processReader
val errReader = errorReader

try {
processWriter?.close()
processReader?.close()
errorReader?.close()
daemonProcess?.destroyForcibly()
} catch (e: Exception) {
LOG.warn("Error stopping daemon", e)
} finally {
daemonProcess = null
processWriter = null
processReader = null
errorReader = null
daemonProcess = null
processWriter = null
processReader = null
errorReader = null

if (process == null && writer == null && reader == null && errReader == null) {
return
}

Thread({
try {
writer?.write("EXIT\n")
writer?.flush()
process?.waitFor(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e: Exception) {
LOG.debug("Error sending EXIT to daemon", e)
}

try {
writer?.close()
reader?.close()
errReader?.close()
process?.destroyForcibly()
} catch (e: Exception) {
LOG.warn("Error stopping daemon", e)
}
}, "compose-daemon-shutdown").apply { isDaemon = true }.start()
}

fun shutdown() {
Expand Down
Loading
Loading