diff --git a/README.md b/README.md index d37c68a..b89b72f 100644 --- a/README.md +++ b/README.md @@ -189,20 +189,19 @@ HappyView joins user records through the catalog for social queries like friends - [x] Parse - [x] UI - [ ] ATProto -- [ ] Album - - [ ] Parse - - [ ] UI - - [ ] ATProto +- [x] Album + - [x] Parse + - [x] UI + - [ ] ATProto (publish image as blob + backlink to record?) - [x] Friends - [x] Parse - [x] UI - [x] Favorite/unfavorite, rival register/deregister, friend detail, Friend VS - [x] Remove/drop friend - [x] ATProto -- [ ] Friend VS (score comparison page) +- [ ] Friend VS - [ ] Parse - [ ] UI - - [ ] ATProto - [x] Search Friends - [x] Parse - [x] UI diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index dd64186..6e8697d 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -22,8 +22,7 @@ android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round" android:usesCleartextTraffic="true" - android:theme="@android:style/Theme.Material.Light.NoActionBar" - tools:replace="android:name"> + android:theme="@android:style/Theme.Material.Light.NoActionBar"> + + + + + + diff --git a/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt b/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt new file mode 100644 index 0000000..d7f0e48 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/derakkuma/share/ImageActions.android.kt @@ -0,0 +1,48 @@ +package com.derakkuma.share + +import android.content.ContentValues +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import androidx.core.content.FileProvider +import com.derakkuma.DerakkumaApp +import java.io.File + +actual suspend fun saveImageToGallery(bytes: ByteArray, fileName: String): Boolean = runCatching { + val context = DerakkumaApp.context + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg") + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES + "/Derakkuma") + put(MediaStore.Images.Media.IS_PENDING, 1) + } + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false + resolver.openOutputStream(uri)?.use { it.write(bytes) } ?: return false + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + true +}.getOrDefault(false) + +actual suspend fun shareImage(bytes: ByteArray, fileName: String, text: String): Boolean = runCatching { + val context = DerakkumaApp.context + val dir = File(context.cacheDir, "shared_images").apply { mkdirs() } + val file = File(dir, fileName) + file.writeBytes(bytes) + val uri: Uri = FileProvider.getUriForFile(context, context.packageName + ".fileprovider", file) + val intent = Intent(Intent.ACTION_SEND).apply { + type = "image/jpeg" + putExtra(Intent.EXTRA_STREAM, uri) + putExtra(Intent.EXTRA_TEXT, text) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + context.startActivity(Intent.createChooser(intent, "Share score photo").addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + true +}.getOrDefault(false) diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt index 592b4a4..e5ba5a6 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/App.kt @@ -102,6 +102,7 @@ object Routes { const val PLAYER_DATA = "more/playerData" const val COLLECTION = "more/collection" const val STAMP_CARD = "more/stampCard" + const val ALBUM = "more/album" const val SETTINGS = "more/settings" const val FAVORITES = "more/favorites" const val PLAYER_NAME = "more/playerName" @@ -486,6 +487,7 @@ fun App(authStore: AuthStore, cacheStore: CacheStore, playStore: PlayStore) { onPlayerData = { navController.navigate(Routes.PLAYER_DATA) }, onCollection = { navController.navigate(Routes.COLLECTION) }, onStampCard = { navController.navigate(Routes.STAMP_CARD) }, + onAlbum = { navController.navigate(Routes.ALBUM) }, onSettings = { navController.navigate(Routes.SETTINGS) }, onFavorites = { navController.navigate(Routes.FAVORITES) }, onPlayerName = { navController.navigate(Routes.PLAYER_NAME) }, @@ -508,6 +510,9 @@ fun App(authStore: AuthStore, cacheStore: CacheStore, playStore: PlayStore) { composable(Routes.STAMP_CARD) { StampCardTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode) } + composable(Routes.ALBUM) { + AlbumTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode) + } composable(Routes.SETTINGS) { SettingsTab(client = client, cookie = cookie, maintenanceMode = maintenanceMode) } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt index f7aafc2..997041e 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/MaimaiClient.kt @@ -428,6 +428,25 @@ class MaimaiClient( suspend fun fetchStampCards(cookie: String = currentCookie()): StampCardPage = parseStampCardPage(fetchPage("/playerData/stampCard/", cookie)) + suspend fun fetchPhotoAlbum(cookie: String = currentCookie()): PhotoAlbumPage = parsePhotoAlbumPage(fetchPage("/playerData/photo/", cookie)) + + suspend fun fetchImageBytes(url: String, cookie: String = currentCookie()): ByteArray { + val resp = client.get(url) { + header(HttpHeaders.Cookie, cookie) + header(HttpHeaders.UserAgent, UA) + header(HttpHeaders.Accept, "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8") + header(HttpHeaders.AcceptLanguage, "en-US,en;q=0.9") + header(HttpHeaders.Referrer, "$BASE/playerData/photo/") + header(HttpHeaders.CacheControl, "no-cache") + header(HttpHeaders.Pragma, "no-cache") + header("Sec-Fetch-Dest", "document") + header("Sec-Fetch-Mode", "navigate") + header("Sec-Fetch-Site", "same-origin") + header("Upgrade-Insecure-Requests", "1") + } + return resp.body() + } + suspend fun setStampCard(item: StampCardItem, cookie: String = currentCookie()): Boolean { if (item.idx.isBlank() || item.token.isBlank()) return false val path = item.setAction.ifBlank { "/playerData/stampCard/set/" } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt index 6ea6146..5c1578a 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Models.kt @@ -644,6 +644,26 @@ data class CircleFestaHistory( val idx: String = "", ) +@Serializable +data class PhotoAlbumPage( + val items: List = emptyList(), +) + +@Serializable +data class PhotoAlbumItem( + val songName: String = "", + val difficulty: String = "", + val edition: String = "", + val musicKind: String = "", + val musicKindImageUrl: String? = null, + val date: String = "", + val location: String = "", + val imageUrl: String = "", +) { + val shareText: String + get() = "Check out my maimai DX score on $songName (${difficulty.replaceFirstChar { it.uppercase() }})!\nShared via derakkuma.com\n\n#maimai思い出写真 #maimaidx" +} + // ---- Shared helpers ---- // Cover art: prefer CDN, fall back to scraped SEGA URL diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt index e90bdc1..b658021 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/data/Parsers.kt @@ -1096,6 +1096,30 @@ fun parseStampCardPage(html: String): StampCardPage { return StampCardPage(items) } +fun parsePhotoAlbumPage(html: String): PhotoAlbumPage { + val items = Regex("""
([\s\S]*?)(?=
|)""").findAll(html).mapNotNull { match -> + val block = match.groupValues[1] + val imageUrl = absoluteImg(Regex("""]*src="([^"]*/img/photo/user/[^"]+)"""").find(block)?.groupValues?.getOrNull(1)) ?: return@mapNotNull null + val musicKindImageUrl = absoluteImg(Regex("""]*src="([^"]*/img/music_(?:dx|standard)\.png[^"]*)""", RegexOption.IGNORE_CASE).find(block)?.groupValues?.getOrNull(1)) + val musicKind = Regex("""music_(dx|standard)\.png""", RegexOption.IGNORE_CASE).find(block)?.groupValues?.getOrNull(1)?.lowercase().orEmpty() + val difficulty = Regex("""diff_(\w+)\.png""", RegexOption.IGNORE_CASE).find(block)?.groupValues?.getOrNull(1)?.lowercase().orEmpty() + val date = re(block, Regex("""block_info[^>]*>([^<]+)"""))?.trim().orEmpty() + val songName = re(block, Regex("""black_block[^>]*>([\s\S]*?)
"""))?.let { htmlText(it) }.orEmpty() + val location = re(block, Regex("""see_through_block[^>]*>([\s\S]*?)
"""))?.let { htmlText(it) }.orEmpty() + PhotoAlbumItem( + songName = songName, + difficulty = difficulty, + edition = if (musicKind == "dx") "DX" else if (musicKind == "standard") "Standard" else "", + musicKind = musicKind, + musicKindImageUrl = musicKindImageUrl, + date = date, + location = location, + imageUrl = imageUrl, + ) + }.toList() + return PhotoAlbumPage(items) +} + fun parseTourMemberFormationPage(html: String): TourMemberFormationPage { val token = re(html, Regex("""name="token"\s+value="([^"]*)""")) ?: "" val blocks = html.split(Regex("""(?=
)""")) diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/share/ImageActions.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/share/ImageActions.kt new file mode 100644 index 0000000..1164c01 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/share/ImageActions.kt @@ -0,0 +1,5 @@ +package com.derakkuma.share + +expect suspend fun saveImageToGallery(bytes: ByteArray, fileName: String): Boolean + +expect suspend fun shareImage(bytes: ByteArray, fileName: String, text: String): Boolean diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AlbumTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AlbumTab.kt new file mode 100644 index 0000000..ce84a59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/AlbumTab.kt @@ -0,0 +1,230 @@ +package com.derakkuma.ui.tabs + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.derakkuma.data.MaimaiClient +import com.derakkuma.data.PhotoAlbumItem +import com.derakkuma.data.PhotoAlbumPage +import com.derakkuma.share.saveImageToGallery +import com.derakkuma.share.shareImage +import com.derakkuma.ui.components.EmptyState +import com.derakkuma.ui.components.ErrorState +import com.derakkuma.ui.components.ExpressiveButton +import com.derakkuma.ui.components.ExpressiveGlassCard +import com.derakkuma.ui.components.ExpressiveLoadingIndicator +import com.derakkuma.ui.components.ExpressiveTextButton +import com.derakkuma.ui.images.MaimaiRemoteImage +import com.derakkuma.ui.theme.difficultyColor +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.decodeToImageBitmap + +@Composable +fun AlbumTab( + client: MaimaiClient, + cookie: String, + maintenanceMode: Boolean = false, +) { + var page by remember { mutableStateOf(null) } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(null) } + var busyUrl by remember { mutableStateOf(null) } + var message by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + + fun load() { + if (maintenanceMode) { + loading = false + error = "Photo album is unavailable during maintenance" + return + } + scope.launch { + loading = true + error = null + try { + page = client.fetchPhotoAlbum(cookie) + } catch (t: Throwable) { + error = t.message ?: "Failed to load photo album" + } finally { + loading = false + } + } + } + + LaunchedEffect(cookie, maintenanceMode) { load() } + + Column( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Text("Album", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface) + message?.let { + Text(it, fontSize = 12.sp, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(bottom = 8.dp)) + } + + when { + loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { ExpressiveLoadingIndicator() } + error != null -> ErrorState(error!!, onRetry = { load() }) + page?.items.isNullOrEmpty() -> EmptyState("No album photos found", Modifier.fillMaxWidth().padding(16.dp)) + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(bottom = 88.dp), + ) { + items(page!!.items, key = { it.imageUrl }) { item -> + AlbumPhotoCard( + client = client, + cookie = cookie, + item = item, + busy = busyUrl == item.imageUrl, + onSave = { + scope.launch { + busyUrl = item.imageUrl + message = null + val ok = runCatching { + saveImageToGallery(albumPhotoBytes(client, cookie, item.imageUrl), item.fileName()) + }.getOrDefault(false) + message = if (ok) "Saved to Photos" else "Could not save image" + busyUrl = null + } + }, + onShare = { + scope.launch { + busyUrl = item.imageUrl + message = null + val ok = runCatching { + shareImage(albumPhotoBytes(client, cookie, item.imageUrl), item.fileName(), item.shareText) + }.getOrDefault(false) + if (!ok) message = "Could not share image" + busyUrl = null + } + }, + ) + } + } + } + } +} + +@Composable +private fun AlbumPhotoCard(client: MaimaiClient, cookie: String, item: PhotoAlbumItem, busy: Boolean, onSave: () -> Unit, onShare: () -> Unit) { + var photo by remember(item.imageUrl) { mutableStateOf(AlbumPhotoMemoryCache.get(item.imageUrl)) } + LaunchedEffect(item.imageUrl, cookie) { + if (photo == null) { + photo = runCatching { + albumPhotoBytes(client, cookie, item.imageUrl).decodeToImageBitmap().also { bitmap -> + AlbumPhotoMemoryCache.put(item.imageUrl, bitmap) + } + }.getOrNull() + } + } + + ExpressiveGlassCard(modifier = Modifier.fillMaxWidth(), cornerRadius = 24.dp) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(item.songName, modifier = Modifier.weight(1f), fontSize = 15.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, maxLines = 2, overflow = TextOverflow.Ellipsis) + if (item.musicKindImageUrl != null) { + MaimaiRemoteImage(item.musicKindImageUrl, modifier = Modifier.height(16.dp), contentDescription = item.edition) + } + } + if (photo != null) { + Image( + bitmap = photo!!, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)), + contentDescription = item.songName, + contentScale = ContentScale.FillWidth, + ) + } else { + Box(Modifier.fillMaxWidth().height(220.dp), contentAlignment = Alignment.Center) { + ExpressiveLoadingIndicator() + } + } + AlbumMeta(item) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) { + ExpressiveButton(onClick = onShare, enabled = !busy, modifier = Modifier.weight(1f)) { + androidx.compose.material3.Icon(Icons.Outlined.IosShare, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text("Share", fontSize = 13.sp) + } + ExpressiveTextButton(onClick = onSave, enabled = !busy, modifier = Modifier.weight(1f)) { + androidx.compose.material3.Icon(Icons.Outlined.Download, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(6.dp)) + Text(if (busy) "Working…" else "Save", fontSize = 13.sp) + } + } + } + } +} + +@Composable +private fun AlbumMeta(item: PhotoAlbumItem) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + Text(item.difficulty.replaceFirstChar { it.uppercase() }, fontSize = 12.sp, fontWeight = FontWeight.Bold, color = difficultyColor(item.difficulty)) + if (item.edition.isNotBlank()) Text(item.edition, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + if (item.date.isNotBlank()) Text(item.date, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (item.location.isNotBlank()) { + Text(item.location, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} + +private fun PhotoAlbumItem.fileName(): String { + val safeSong = songName.replace(Regex("[^A-Za-z0-9._-]+"), "_").trim('_').ifBlank { "maimai" } + val safeDate = date.replace(Regex("[^0-9]+"), "_").trim('_') + return "derakkuma_${safeSong}_${difficulty}_${safeDate}.jpg" +} + +private object AlbumPhotoMemoryCache { + private val images = linkedMapOf() + private val bytes = linkedMapOf() + private const val MAX_IMAGES = 20 + + fun get(url: String): ImageBitmap? = images[url] + fun getBytes(url: String): ByteArray? = bytes[url] + + fun put(url: String, bitmap: ImageBitmap) { + images[url] = bitmap + trim() + } + + fun putBytes(url: String, data: ByteArray) { + bytes[url] = data + trim() + } + + private fun trim() { + while (images.size > MAX_IMAGES) { + val oldest = images.keys.first() + images.remove(oldest) + bytes.remove(oldest) + } + while (bytes.size > MAX_IMAGES) { + val oldest = bytes.keys.first() + bytes.remove(oldest) + images.remove(oldest) + } + } +} + +private suspend fun albumPhotoBytes(client: MaimaiClient, cookie: String, url: String): ByteArray = + AlbumPhotoMemoryCache.getBytes(url) ?: client.fetchImageBytes(url, cookie).also { data -> + AlbumPhotoMemoryCache.putBytes(url, data) + } diff --git a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MoreTab.kt b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MoreTab.kt index 45afebd..b80f706 100644 --- a/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MoreTab.kt +++ b/composeApp/src/commonMain/kotlin/com/derakkuma/ui/tabs/MoreTab.kt @@ -47,6 +47,7 @@ fun MoreMenu( onPlayerData: () -> Unit, onCollection: () -> Unit, onStampCard: () -> Unit, + onAlbum: () -> Unit, onSettings: () -> Unit, onFavorites: () -> Unit, onPlayerName: () -> Unit, @@ -149,9 +150,8 @@ fun MoreMenu( imageIcon = BundledImage.NavAlbum.takeIf { funIcons }, title = "Album", description = "", - onClick = {}, - disabled = true, - disabledLabel = "Coming soon", + onClick = onAlbum, + disabled = maintenanceMode, containerColor = albumCardTint, modifier = Modifier.weight(1f).fillMaxHeight(), ) diff --git a/composeApp/src/iosMain/kotlin/com/derakkuma/share/ImageActions.ios.kt b/composeApp/src/iosMain/kotlin/com/derakkuma/share/ImageActions.ios.kt new file mode 100644 index 0000000..f4a6a13 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/derakkuma/share/ImageActions.ios.kt @@ -0,0 +1,35 @@ +package com.derakkuma.share + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.Foundation.NSData +import platform.Foundation.NSTemporaryDirectory +import platform.Foundation.NSURL +import platform.Foundation.create +import platform.Foundation.writeToURL +import platform.Photos.PHAssetChangeRequest +import platform.Photos.PHPhotoLibrary +import platform.UIKit.UIActivityViewController +import platform.UIKit.UIApplication +import platform.UIKit.UIImage + +@OptIn(ExperimentalForeignApi::class) +private fun ByteArray.toNSData(): NSData = usePinned { pinned -> NSData.create(bytes = pinned.addressOf(0), length = size.toULong()) } + +actual suspend fun saveImageToGallery(bytes: ByteArray, fileName: String): Boolean = runCatching { + val image = UIImage(data = bytes.toNSData()) ?: return false + PHPhotoLibrary.sharedPhotoLibrary().performChanges({ + PHAssetChangeRequest.creationRequestForAssetFromImage(image) + }, completionHandler = { _, _ -> }) + true +}.getOrDefault(false) + +actual suspend fun shareImage(bytes: ByteArray, fileName: String, text: String): Boolean = runCatching { + val url = NSURL.fileURLWithPath(NSTemporaryDirectory() + fileName) + bytes.toNSData().writeToURL(url, atomically = true) + val controller = UIActivityViewController(activityItems = listOf(text, url), applicationActivities = null) + val root = UIApplication.sharedApplication.keyWindow?.rootViewController ?: return false + root.presentViewController(controller, animated = true, completion = null) + true +}.getOrDefault(false)