基本界面
0
app/.gitignore → app-default/.gitignore
vendored
108
app-default/build.gradle.kts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import org.jetbrains.kotlin.konan.properties.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "moe.tabidachi.emulator"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "moe.tabidachi.emulator"
|
||||||
|
minSdk = 23
|
||||||
|
targetSdk = 28
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
}
|
||||||
|
signingConfigs {
|
||||||
|
val properties = Properties().apply {
|
||||||
|
load(FileInputStream(project.rootProject.file("keystore.properties")))
|
||||||
|
}
|
||||||
|
create("release") {
|
||||||
|
storeFile = file(properties.getProperty("signing.storeFile"))
|
||||||
|
storePassword = properties.getProperty("signing.storePassword")
|
||||||
|
keyAlias = properties.getProperty("signing.keyAlias")
|
||||||
|
keyPassword = properties.getProperty("signing.keyPassword")
|
||||||
|
enableV1Signing = true
|
||||||
|
enableV2Signing = true
|
||||||
|
enableV3Signing = true
|
||||||
|
enableV4Signing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
|
||||||
|
)
|
||||||
|
//signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.tv.material)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(project(":core"))
|
||||||
|
implementation(project(":ui"))
|
||||||
|
// hilt
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
// navigation
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
implementation(libs.compose.navigation)
|
||||||
|
implementation(libs.kotlin.serialization)
|
||||||
|
implementation(libs.datastore)
|
||||||
|
implementation(libs.material.icons.extended)
|
||||||
|
// ktor
|
||||||
|
implementation(libs.ktor.client.core)
|
||||||
|
implementation(libs.ktor.client.cio)
|
||||||
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
|
implementation(libs.ktor.client.logging)
|
||||||
|
// coil
|
||||||
|
implementation(libs.coil.compose)
|
||||||
|
implementation(libs.coil.network.ktor3)
|
||||||
|
// work
|
||||||
|
implementation(libs.work.runtime.ktx)
|
||||||
|
// room
|
||||||
|
implementation(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
android:required="false" />
|
android:required="false" />
|
||||||
@@ -10,6 +13,7 @@
|
|||||||
android:required="false" />
|
android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".Emulator"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:banner="@mipmap/ic_launcher"
|
android:banner="@mipmap/ic_launcher"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
21
app-default/src/main/java/moe/tabidachi/emulator/Emulator.kt
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package moe.tabidachi.emulator
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil3.ImageLoader
|
||||||
|
import coil3.PlatformContext
|
||||||
|
import coil3.SingletonImageLoader
|
||||||
|
import coil3.memory.MemoryCache
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class Emulator : Application(), SingletonImageLoader.Factory {
|
||||||
|
override fun newImageLoader(context: PlatformContext): ImageLoader {
|
||||||
|
return ImageLoader.Builder(context)
|
||||||
|
.memoryCache(
|
||||||
|
MemoryCache.Builder()
|
||||||
|
.maxSizeBytes(1024 * 1024 * 1024)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,42 +4,26 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.tv.material3.Surface
|
import androidx.tv.material3.Surface
|
||||||
import androidx.tv.material3.Text
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import moe.tabidachi.emulator.ui.SharedNavHost
|
||||||
import moe.tabidachi.emulator.ui.theme.EmulatorTheme
|
import moe.tabidachi.emulator.ui.theme.EmulatorTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
EmulatorTheme {
|
EmulatorTheme(isInDarkTheme = true) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
shape = RectangleShape
|
shape = RectangleShape
|
||||||
) {
|
) {
|
||||||
Greeting("Android")
|
SharedNavHost()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(
|
|
||||||
text = "Hello $name!",
|
|
||||||
modifier = modifier
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
|
||||||
@Composable
|
|
||||||
fun GreetingPreview() {
|
|
||||||
EmulatorTheme {
|
|
||||||
Greeting("Android")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package moe.tabidachi.emulator.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import moe.tabidachi.emulator.ui.home.HomeRoute
|
||||||
|
import moe.tabidachi.emulator.ui.home.home
|
||||||
|
import moe.tabidachi.emulator.ui.roms.roms
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SharedNavHost(
|
||||||
|
startDestination: Any = HomeRoute
|
||||||
|
) {
|
||||||
|
val navController: NavHostController = rememberNavController()
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = startDestination
|
||||||
|
) {
|
||||||
|
home(navController)
|
||||||
|
roms(navController)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun GamepadIndicatorBar(
|
||||||
|
leadingContent: @Composable (() -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f))
|
||||||
|
.padding(8.dp)
|
||||||
|
) {
|
||||||
|
ProvideTextStyle(MaterialTheme.typography.titleLarge) {
|
||||||
|
leadingContent?.invoke()
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TvPreview
|
||||||
|
@Composable
|
||||||
|
fun GamepadIndicatorBarPreview() {
|
||||||
|
PreviewSurface(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
GamepadIndicatorBar(
|
||||||
|
leadingContent = {
|
||||||
|
Text("114514 GAMES")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import moe.tabidachi.emulator.ui.common.MediaChangeListener
|
||||||
|
import moe.tabidachi.emulator.ui.roms.navigateToRoms
|
||||||
|
|
||||||
|
fun NavGraphBuilder.home(navController: NavHostController) = composable<HomeRoute> {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val viewModel: HomeViewModel = hiltViewModel()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val actions = remember(viewModel.actions) {
|
||||||
|
viewModel.actions.copy(
|
||||||
|
onActivityFinished = {
|
||||||
|
(context as? Activity)?.finish()
|
||||||
|
}, navigateToRoms = navController::navigateToRoms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
HomeScreen(state = state, actions = actions)
|
||||||
|
MediaChangeListener(onMediaChange = actions.onMediaChange)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data object HomeRoute
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyRow
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Border
|
||||||
|
import androidx.tv.material3.Card
|
||||||
|
import androidx.tv.material3.CardDefaults
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import moe.tabidachi.emulator.R
|
||||||
|
import moe.tabidachi.emulator.common.Permissions
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorListItem
|
||||||
|
import moe.tabidachi.emulator.ui.common.AssetsExtractDialog
|
||||||
|
import moe.tabidachi.emulator.ui.common.GamepadIndicatorBar
|
||||||
|
import moe.tabidachi.emulator.ui.common.NoSdcardDialog
|
||||||
|
import moe.tabidachi.emulator.ui.common.PermissionDialog
|
||||||
|
import moe.tabidachi.emulator.ui.common.PreviewSurface
|
||||||
|
import moe.tabidachi.emulator.ui.common.TvPreview
|
||||||
|
import moe.tabidachi.emulator.ui.common.rememberPivotBringIntoViewSpec
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class)
|
||||||
|
@Composable
|
||||||
|
fun HomeScreen(
|
||||||
|
state: HomeViewState,
|
||||||
|
actions: HomeActions
|
||||||
|
) {
|
||||||
|
val pageCount = Int.MAX_VALUE
|
||||||
|
val initialPage = pageCount / 2
|
||||||
|
val pagerState = rememberPagerState(
|
||||||
|
initialPage = initialPage,
|
||||||
|
pageCount = { pageCount }
|
||||||
|
)
|
||||||
|
val listState = rememberLazyListState(
|
||||||
|
initialFirstVisibleItemIndex = initialPage,
|
||||||
|
initialFirstVisibleItemScrollOffset = 0
|
||||||
|
)
|
||||||
|
val size = state.emulatorList.size
|
||||||
|
var focusedItem by remember { mutableStateOf<EmulatorListItem?>(null) }
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState
|
||||||
|
) { page: Int ->
|
||||||
|
if (size > 0) {
|
||||||
|
val item = state.emulatorList[page % size]
|
||||||
|
AsyncImage(
|
||||||
|
model = item.background,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec(
|
||||||
|
parentFraction = 0.25f,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LazyRow(
|
||||||
|
state = listState,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(vertical = 16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.focusRestorer()
|
||||||
|
) {
|
||||||
|
if (size > 0) items(
|
||||||
|
count = pageCount,
|
||||||
|
) { index ->
|
||||||
|
val item = state.emulatorList[index % size]
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) {
|
||||||
|
pagerState.animateScrollToPage(index)
|
||||||
|
focusedItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Card(
|
||||||
|
onClick = {
|
||||||
|
actions.navigateToRoms(item.id)
|
||||||
|
},
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
border = CardDefaults.border(
|
||||||
|
focusedBorder = Border(
|
||||||
|
border = BorderStroke(
|
||||||
|
width = 2.dp,
|
||||||
|
color = Color(0xFFFF9C40)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
), modifier = Modifier.size(200.dp)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = item.icon,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GamepadIndicatorBar(
|
||||||
|
leadingContent = {
|
||||||
|
Text(text = stringResource(R.string.game_size, focusedItem?.romsSize ?: 0))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(state.emulatorList) {
|
||||||
|
if (state.emulatorList.isNotEmpty()) listState.animateScrollToItem(listState.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
|
NoSdcardDialog(
|
||||||
|
visible = state.isSdcardEmpty,
|
||||||
|
onDismissRequest = {},
|
||||||
|
onDismiss = actions.onActivityFinished
|
||||||
|
)
|
||||||
|
AssetsExtractDialog(
|
||||||
|
visible = state.isAssetsExtracting,
|
||||||
|
progress = state.progress
|
||||||
|
)
|
||||||
|
PermissionDialog(
|
||||||
|
visible = state.permissionDialogVisible,
|
||||||
|
permissions = Permissions,
|
||||||
|
onDismissRequest = {
|
||||||
|
actions.onPermissionDialogVisibleChange(false)
|
||||||
|
},
|
||||||
|
onGranted = actions.onPermissionGranted,
|
||||||
|
onDenied = actions.onActivityFinished
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TvPreview
|
||||||
|
@Composable
|
||||||
|
private fun HomeScreenPreview() {
|
||||||
|
PreviewSurface {
|
||||||
|
HomeScreen(
|
||||||
|
state = HomeViewState(
|
||||||
|
isAssetsExtracting = false,
|
||||||
|
progress = 0.5f,
|
||||||
|
permissionDialogVisible = true
|
||||||
|
),
|
||||||
|
actions = HomeActions()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.home
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import moe.tabidachi.emulator.common.ktx.TAG
|
||||||
|
import moe.tabidachi.emulator.data.AssetsState
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorListItem
|
||||||
|
import moe.tabidachi.emulator.data.PermissionState
|
||||||
|
import moe.tabidachi.emulator.di.AssetsStateFlow
|
||||||
|
import moe.tabidachi.emulator.di.PermissionStateFlow
|
||||||
|
import moe.tabidachi.emulator.di.SdcardPaths
|
||||||
|
import moe.tabidachi.emulator.domain.CheckPermissionUseCase
|
||||||
|
import moe.tabidachi.emulator.domain.ExtractAssetsUseCase
|
||||||
|
import moe.tabidachi.emulator.domain.GetAssetsFileUseCase
|
||||||
|
import moe.tabidachi.emulator.domain.GetAssetsStateUseCase
|
||||||
|
import moe.tabidachi.emulator.domain.GetEmulatorListUseCase
|
||||||
|
import moe.tabidachi.emulator.domain.GetSdcardPathsUseCase
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class HomeViewModel @Inject constructor(
|
||||||
|
private val getAssetsStateUseCase: GetAssetsStateUseCase,
|
||||||
|
@AssetsStateFlow
|
||||||
|
private val assetsState: MutableStateFlow<AssetsState>,
|
||||||
|
@PermissionStateFlow
|
||||||
|
private val permissionState: MutableStateFlow<PermissionState>,
|
||||||
|
@SdcardPaths
|
||||||
|
private val sdcardPaths: MutableStateFlow<Set<String>>,
|
||||||
|
private val checkPermissionUseCase: CheckPermissionUseCase,
|
||||||
|
private val getSdcardPathsUseCase: GetSdcardPathsUseCase,
|
||||||
|
getAssetsFileUseCase: GetAssetsFileUseCase,
|
||||||
|
private val extractAssetsUseCase: ExtractAssetsUseCase,
|
||||||
|
private val getEmulatorListUseCase: GetEmulatorListUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(HomeViewState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
val actions = HomeActions(
|
||||||
|
onMediaChange = ::onMediaChange,
|
||||||
|
onExtractDialogVisibleChange = ::onExtractDialogVisibleChange,
|
||||||
|
onPermissionDialogVisibleChange = ::onPermissionDialogVisibleChange,
|
||||||
|
onPermissionGranted = ::onPermissionGranted
|
||||||
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
permissionState.onEach {
|
||||||
|
when (it) {
|
||||||
|
PermissionState.None -> checkPermissionUseCase()
|
||||||
|
PermissionState.Granted -> getSdcardPathsUseCase()
|
||||||
|
PermissionState.Denied -> _state.update { it.copy(permissionDialogVisible = true) }
|
||||||
|
}
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
assetsState.onEach {
|
||||||
|
if (it == AssetsState.None) getAssetsStateUseCase()
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
combine(
|
||||||
|
assetsState,
|
||||||
|
getAssetsFileUseCase(),
|
||||||
|
) { assetsState, file ->
|
||||||
|
assetsState to file
|
||||||
|
}.onEach { (assetsState, file) ->
|
||||||
|
Log.d(TAG, "$assetsState, getAssetsFileUseCase($file)")
|
||||||
|
if (assetsState == AssetsState.NotExtracted) {
|
||||||
|
try {
|
||||||
|
_state.update { it.copy(isAssetsExtracting = true) }
|
||||||
|
extractAssetsUseCase(
|
||||||
|
file,
|
||||||
|
onProgress = { progress ->
|
||||||
|
_state.update { it.copy(progress = progress) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.d(TAG, "解压失败", e)
|
||||||
|
} finally {
|
||||||
|
_state.update { it.copy(isAssetsExtracting = false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
|
getEmulatorListUseCase().collect { emulatorList: List<EmulatorListItem> ->
|
||||||
|
_state.update { it.copy(emulatorList = emulatorList) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
combine(permissionState, sdcardPaths) { permissionState, sdcardPaths ->
|
||||||
|
permissionState to sdcardPaths
|
||||||
|
}.filter { it.first == PermissionState.Granted }.map { it.second.isEmpty() }
|
||||||
|
.onEach { isSdcardEmpty ->
|
||||||
|
_state.update { it.copy(isSdcardEmpty = isSdcardEmpty) }
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onMediaChange(action: String) {
|
||||||
|
when (action) {
|
||||||
|
Intent.ACTION_MEDIA_MOUNTED -> getSdcardPathsUseCase()
|
||||||
|
Intent.ACTION_MEDIA_UNMOUNTED -> getSdcardPathsUseCase()
|
||||||
|
Intent.ACTION_MEDIA_EJECT -> Unit
|
||||||
|
Intent.ACTION_MEDIA_REMOVED -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onExtractDialogVisibleChange(value: Boolean) {
|
||||||
|
_state.update { it.copy(isAssetsExtracting = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPermissionDialogVisibleChange(value: Boolean) {
|
||||||
|
_state.update { it.copy(permissionDialogVisible = value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onPermissionGranted() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
checkPermissionUseCase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class HomeViewState(
|
||||||
|
val isAssetsExtracting: Boolean = false,
|
||||||
|
val progress: Float = 0.0f,
|
||||||
|
val permissionDialogVisible: Boolean = false,
|
||||||
|
val emulatorList: List<EmulatorListItem> = emptyList(),
|
||||||
|
val isSdcardEmpty: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HomeActions(
|
||||||
|
val onMediaChange: (String) -> Unit = {},
|
||||||
|
val onExtractDialogVisibleChange: (Boolean) -> Unit = {},
|
||||||
|
val onPermissionDialogVisibleChange: (Boolean) -> Unit = {},
|
||||||
|
val onPermissionGranted: () -> Unit = {},
|
||||||
|
val onActivityFinished: () -> Unit = {},
|
||||||
|
val navigateToRoms: (id: String) -> Unit = {},
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.roms
|
||||||
|
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
fun NavGraphBuilder.roms(navController: NavHostController) = composable<RomsRoute> {
|
||||||
|
val viewModel: RomsViewModel = hiltViewModel()
|
||||||
|
val state by viewModel.state.collectAsState()
|
||||||
|
val actions = remember(viewModel.actions) {
|
||||||
|
viewModel.actions
|
||||||
|
}
|
||||||
|
RomsScreen(state = state, actions = actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class RomsRoute(val id: String)
|
||||||
|
|
||||||
|
fun NavHostController.navigateToRoms(id: String) {
|
||||||
|
navigate(RomsRoute(id))
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.roms
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.LocalBringIntoViewSpec
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.SpanStyle
|
||||||
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.ListItem
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import moe.tabidachi.emulator.common.ktx.digits
|
||||||
|
import moe.tabidachi.emulator.data.RomListItem
|
||||||
|
import moe.tabidachi.emulator.ui.common.PreviewSurface
|
||||||
|
import moe.tabidachi.emulator.ui.common.TvPreview
|
||||||
|
import moe.tabidachi.emulator.ui.common.rememberPivotBringIntoViewSpec
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun RomsScreen(
|
||||||
|
state: RomsViewState,
|
||||||
|
actions: RomsActions
|
||||||
|
) {
|
||||||
|
var focusedItem by remember { mutableStateOf<RomListItem?>(null) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Row {
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalBringIntoViewSpec provides rememberPivotBringIntoViewSpec()
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
itemsIndexed(state.roms) { index, rom ->
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
LaunchedEffect(isFocused) {
|
||||||
|
if (isFocused) focusedItem = rom
|
||||||
|
}
|
||||||
|
ListItem(
|
||||||
|
selected = false,
|
||||||
|
headlineContent = {
|
||||||
|
val text = buildAnnotatedString {
|
||||||
|
withStyle(MaterialTheme.typography.labelSmall.toSpanStyle()) {
|
||||||
|
append("%0${state.roms.size.digits}d".format(index + 1))
|
||||||
|
}
|
||||||
|
append(" ")
|
||||||
|
append(rom.name)
|
||||||
|
}
|
||||||
|
Text(text = text)
|
||||||
|
}, onClick = {
|
||||||
|
|
||||||
|
}, interactionSource = interactionSource
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AsyncImage(
|
||||||
|
model = focusedItem?.imagePath,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@TvPreview
|
||||||
|
@Composable
|
||||||
|
private fun RomsScreenPreview() {
|
||||||
|
PreviewSurface {
|
||||||
|
RomsScreen(
|
||||||
|
state = RomsViewState(),
|
||||||
|
actions = RomsActions()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.roms
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import androidx.navigation.toRoute
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorDataSource
|
||||||
|
import moe.tabidachi.emulator.data.RomListItem
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class RomsViewModel @Inject constructor(
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
getRomsUseCase: GetRomsUseCase
|
||||||
|
) : ViewModel() {
|
||||||
|
private val romId = savedStateHandle.toRoute<RomsRoute>().id
|
||||||
|
private val _state = MutableStateFlow(RomsViewState())
|
||||||
|
val state = _state.asStateFlow()
|
||||||
|
val actions = RomsActions()
|
||||||
|
|
||||||
|
init {
|
||||||
|
getRomsUseCase(romId).onEach { roms ->
|
||||||
|
_state.update { it.copy(roms = roms) }
|
||||||
|
}.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class RomsViewState(
|
||||||
|
val roms: List<RomListItem> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
data class RomsActions(
|
||||||
|
val onClick: () -> Unit = {}
|
||||||
|
)
|
||||||
|
|
||||||
|
class GetRomsUseCase @Inject constructor(
|
||||||
|
private val emulatorDataSource: EmulatorDataSource
|
||||||
|
) {
|
||||||
|
operator fun invoke(romId: String): Flow<List<RomListItem>> {
|
||||||
|
return emulatorDataSource.getRomListByEmulatorId(romId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 982 B After Width: | Height: | Size: 982 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
@@ -1,55 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.application)
|
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.compose)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "moe.tabidachi.emulator"
|
|
||||||
compileSdk = 35
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "moe.tabidachi.emulator"
|
|
||||||
minSdk = 23
|
|
||||||
targetSdk = 35
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0"
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
proguardFiles(
|
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
|
||||||
"proguard-rules.pro"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "11"
|
|
||||||
}
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(libs.androidx.core.ktx)
|
|
||||||
implementation(libs.androidx.appcompat)
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.ui)
|
|
||||||
implementation(libs.androidx.ui.graphics)
|
|
||||||
implementation(libs.androidx.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.tv.material)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.ui.tooling)
|
|
||||||
debugImplementation(libs.androidx.ui.test.manifest)
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,9 @@ plugins {
|
|||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.android) apply false
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
alias(libs.plugins.hilt.android) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
|
alias(libs.plugins.android.library) apply false
|
||||||
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
}
|
}
|
||||||
1
core/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
69
core/build.gradle.kts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "moe.tabidachi.emulator"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 23
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(libs.androidx.core.ktx)
|
||||||
|
api(libs.androidx.appcompat)
|
||||||
|
api(libs.material)
|
||||||
|
// hilt
|
||||||
|
api(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
// navigation
|
||||||
|
api(libs.hilt.navigation.compose)
|
||||||
|
api(libs.compose.navigation)
|
||||||
|
api(libs.kotlin.serialization)
|
||||||
|
api(libs.datastore)
|
||||||
|
// ktor
|
||||||
|
api(libs.ktor.client.core)
|
||||||
|
api(libs.ktor.client.cio)
|
||||||
|
api(libs.ktor.client.content.negotiation)
|
||||||
|
api(libs.ktor.serialization.kotlinx.json)
|
||||||
|
api(libs.ktor.client.logging)
|
||||||
|
// coil
|
||||||
|
api(libs.coil.compose)
|
||||||
|
api(libs.coil.network.ktor3)
|
||||||
|
// work
|
||||||
|
api(libs.work.runtime.ktx)
|
||||||
|
// room
|
||||||
|
api(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
}
|
||||||
0
core/consumer-rules.pro
Normal file
21
core/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package moe.tabidachi.emulator
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("moe.tabidachi.emulator.test", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
core/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package moe.tabidachi.emulator.common
|
||||||
|
|
||||||
|
class GameLauncher(
|
||||||
|
|
||||||
|
) {
|
||||||
|
|
||||||
|
}
|
||||||
13
core/src/main/java/moe/tabidachi/emulator/common/Json.kt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package moe.tabidachi.emulator.common
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
fun SharedJson() = Json {
|
||||||
|
encodeDefaults = true
|
||||||
|
isLenient = true
|
||||||
|
allowSpecialFloatingPointValues = true
|
||||||
|
allowStructuredMapKeys = true
|
||||||
|
prettyPrint = false
|
||||||
|
useArrayPolymorphism = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package moe.tabidachi.emulator.common
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
|
||||||
|
val Permissions = arrayOf(
|
||||||
|
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||||
|
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package moe.tabidachi.emulator.common
|
||||||
|
|
||||||
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
|
|
||||||
|
val AssetsExtracted = booleanPreferencesKey("assets_extracted")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package moe.tabidachi.emulator.common.ktx
|
||||||
|
|
||||||
|
val Any.TAG: String get() = this::class.java.simpleName
|
||||||
90
core/src/main/java/moe/tabidachi/emulator/common/ktx/File.kt
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package moe.tabidachi.emulator.common.ktx
|
||||||
|
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.util.zip.ZipEntry
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解压 ZIP 文件到指定目录
|
||||||
|
*
|
||||||
|
* @param extractToPath 解压目标目录
|
||||||
|
* @param filter 文件过滤函数,接收 ZipEntry,返回 true 表示解压该文件
|
||||||
|
* @param onSuccess 解压成功后的回调
|
||||||
|
* @param onProgress 解压进度回调,范围为 [0.0, 1.0]
|
||||||
|
*/
|
||||||
|
suspend fun File.unzip(
|
||||||
|
extractToPath: File,
|
||||||
|
filter: (ZipEntry) -> Boolean = { true },
|
||||||
|
onSuccess: () -> Unit = {},
|
||||||
|
onProgress: (Float) -> Unit = {}
|
||||||
|
) = coroutineScope {
|
||||||
|
require(this@unzip.exists() && this@unzip.isFile) { "源文件不存在或不是文件" }
|
||||||
|
require(extractToPath.exists() && extractToPath.isDirectory) { "目标路径不存在或不是目录" }
|
||||||
|
|
||||||
|
// 获取 ZIP 文件中所有未压缩文件的总大小
|
||||||
|
val totalUncompressedSize = getTotalUncompressedSize()
|
||||||
|
var extractedSize = 0L // 已解压的大小
|
||||||
|
|
||||||
|
// 创建 ZipInputStream
|
||||||
|
ZipInputStream(FileInputStream(this@unzip)).use { zipInputStream ->
|
||||||
|
var entry: ZipEntry? = zipInputStream.nextEntry
|
||||||
|
|
||||||
|
while (isActive && entry != null) {
|
||||||
|
// 检查是否需要解压该文件
|
||||||
|
if (filter(entry)) {
|
||||||
|
val entryFile = File(extractToPath, entry.name)
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
// 如果是目录,则创建目录
|
||||||
|
entryFile.mkdirs()
|
||||||
|
} else {
|
||||||
|
// 如果是文件,则解压文件
|
||||||
|
entryFile.parentFile?.mkdirs() // 确保父目录存在
|
||||||
|
FileOutputStream(entryFile).use { outputStream ->
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
var len: Int = 0
|
||||||
|
while (isActive && zipInputStream.read(buffer).also { len = it } > 0) {
|
||||||
|
outputStream.write(buffer, 0, len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新已解压的大小(基于未压缩的文件大小)
|
||||||
|
extractedSize += entry.size
|
||||||
|
// 更新进度
|
||||||
|
onProgress(extractedSize.toFloat() / totalUncompressedSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
zipInputStream.closeEntry()
|
||||||
|
entry = zipInputStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解压完成,调用成功回调
|
||||||
|
if (isActive) onSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 ZIP 文件中所有未压缩文件的总大小
|
||||||
|
*/
|
||||||
|
private suspend fun File.getTotalUncompressedSize(): Long = coroutineScope {
|
||||||
|
var totalSize = 0L
|
||||||
|
|
||||||
|
ZipInputStream(FileInputStream(this@getTotalUncompressedSize)).use { zipInputStream ->
|
||||||
|
var entry: ZipEntry? = zipInputStream.nextEntry
|
||||||
|
|
||||||
|
while (isActive && entry != null) {
|
||||||
|
if (!entry.isDirectory) {
|
||||||
|
totalSize += entry.size
|
||||||
|
}
|
||||||
|
zipInputStream.closeEntry()
|
||||||
|
entry = zipInputStream.nextEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return@coroutineScope totalSize
|
||||||
|
}
|
||||||
14
core/src/main/java/moe/tabidachi/emulator/common/ktx/Int.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package moe.tabidachi.emulator.common.ktx
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.log10
|
||||||
|
|
||||||
|
val Int.digits: Int
|
||||||
|
get() = when {
|
||||||
|
this < 10 -> 1
|
||||||
|
this < 100 -> 2
|
||||||
|
this < 1000 -> 3
|
||||||
|
this < 10000 -> 4
|
||||||
|
this < 100000 -> 5
|
||||||
|
else -> (log10(abs(toDouble())) + 1).toInt()
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
enum class AssetsState {
|
||||||
|
None, Extracted, NotExtracted
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
interface DataSource
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param extensions 游戏扩展名
|
||||||
|
* @param iconPath 模拟器图标的相对路径
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EmulatorConfig(
|
||||||
|
@SerialName("title")
|
||||||
|
val title: String,
|
||||||
|
@SerialName("extensions")
|
||||||
|
val extensions: List<String> = emptyList(),
|
||||||
|
@SerialName("icon_path")
|
||||||
|
val iconPath: String? = null,
|
||||||
|
@SerialName("background_path")
|
||||||
|
val backgroundPath: String? = null,
|
||||||
|
@SerialName("roms_paths")
|
||||||
|
val romsPaths: List<String> = emptyList(),
|
||||||
|
@SerialName("images_paths")
|
||||||
|
val imagesPaths: List<String> = emptyList(),
|
||||||
|
@SerialName("roms_scan_mode")
|
||||||
|
val romsScanMode: Int = 0,
|
||||||
|
@SerialName("roms_size")
|
||||||
|
val romsSize: Int = 0,
|
||||||
|
@SerialName("roms")
|
||||||
|
val roms: List<Rom> = emptyList()
|
||||||
|
)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import moe.tabidachi.emulator.common.ktx.TAG
|
||||||
|
import moe.tabidachi.emulator.di.SdcardPaths
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface EmulatorDataSource : DataSource {
|
||||||
|
val storageConfigList: Flow<List<Pair<String, StorageConfig>>>
|
||||||
|
fun getAssetsFile(): Flow<File>
|
||||||
|
fun getEmulatorList(): Flow<List<EmulatorListItem>>
|
||||||
|
fun getRomListByEmulatorId(id: String): Flow<List<RomListItem>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class SdcardEmulatorDataSource @Inject constructor(
|
||||||
|
@SdcardPaths
|
||||||
|
private val sdcardPaths: MutableStateFlow<Set<String>>,
|
||||||
|
private val json: Json
|
||||||
|
) : EmulatorDataSource {
|
||||||
|
override val storageConfigList: Flow<List<Pair<String, StorageConfig>>> =
|
||||||
|
sdcardPaths/*.distinctUntilChanged { old, new ->
|
||||||
|
old.containsAll(new)
|
||||||
|
}*/.map { paths: Set<String> ->
|
||||||
|
paths.map { root: String ->
|
||||||
|
root to File(root, "config.json")
|
||||||
|
}.mapNotNull { (root, file) ->
|
||||||
|
runCatching {
|
||||||
|
root to json.decodeFromString<StorageConfig>(file.readText())
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, it.message, it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAssetsFile(): Flow<File> {
|
||||||
|
return storageConfigList.mapNotNull {
|
||||||
|
it.map { (root, storageConfig) ->
|
||||||
|
File(root, storageConfig.assetsPath)
|
||||||
|
}.firstOrNull {
|
||||||
|
it.exists()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getEmulatorConfigList(): Flow<List<Pair<String, EmulatorConfig>>> {
|
||||||
|
return storageConfigList.map { configs ->
|
||||||
|
configs.map { (root, config) ->
|
||||||
|
config.emulatorConfigPaths.map { path: String ->
|
||||||
|
File(root, path)
|
||||||
|
}.filter {
|
||||||
|
val exists = it.exists()
|
||||||
|
if (!exists) Log.d(TAG, "$it 文件不存在")
|
||||||
|
exists
|
||||||
|
}.mapNotNull { file ->
|
||||||
|
runCatching {
|
||||||
|
root to json.decodeFromString<EmulatorConfig>(file.readText())
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "$file 反序列化失败", it)
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
}.flatten()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getEmulatorList(): Flow<List<EmulatorListItem>> {
|
||||||
|
return getEmulatorConfigList().map {
|
||||||
|
it.map { (root, config) ->
|
||||||
|
EmulatorListItem(
|
||||||
|
id = root,
|
||||||
|
icon = File(root, config.iconPath ?: ""),
|
||||||
|
title = config.title,
|
||||||
|
background = File(root, config.backgroundPath ?: ""),
|
||||||
|
romsSize = config.roms.size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRomListByEmulatorId(id: String): Flow<List<RomListItem>> {
|
||||||
|
return getEmulatorConfigList().map {
|
||||||
|
it.first { it.first == id }
|
||||||
|
}.map { (root, config) ->
|
||||||
|
config.roms.map {
|
||||||
|
RomListItem(
|
||||||
|
name = it.name ?: "",
|
||||||
|
path = File(root, it.path),
|
||||||
|
imagePath = File(root, it.imagePath ?: "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class EmulatorListItem(
|
||||||
|
val id: String,
|
||||||
|
val icon: File,
|
||||||
|
val title: String,
|
||||||
|
val background: File,
|
||||||
|
val romsSize: Int,
|
||||||
|
)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface EmulatorRepository : Repository {
|
||||||
|
fun getAssetsFile(): Flow<File>
|
||||||
|
fun getEmulatorList(): Flow<List<EmulatorListItem>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultEmulatorRepository @Inject constructor(
|
||||||
|
private val emulatorDataSource: EmulatorDataSource
|
||||||
|
) : EmulatorRepository {
|
||||||
|
override fun getAssetsFile(): Flow<File> {
|
||||||
|
return emulatorDataSource.getAssetsFile()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getEmulatorList(): Flow<List<EmulatorListItem>> {
|
||||||
|
return emulatorDataSource.getEmulatorList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
enum class PermissionState {
|
||||||
|
None, Granted, Denied
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface PreferencesDataSource : DataSource {
|
||||||
|
operator fun <T> get(key: Preferences.Key<T>): Flow<T?>
|
||||||
|
operator fun <T> set(key: Preferences.Key<T>, value: T)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultPreferencesDataSource @Inject constructor(
|
||||||
|
@ApplicationContext
|
||||||
|
private val context: Context,
|
||||||
|
) : PreferencesDataSource {
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val name: String = "default"
|
||||||
|
private val Context.dataStore by preferencesDataStore(name, scope = scope)
|
||||||
|
private val dataStore: DataStore<Preferences> = context.dataStore
|
||||||
|
private val preferences: Flow<Preferences> = dataStore.data
|
||||||
|
|
||||||
|
override operator fun <T> get(key: Preferences.Key<T>): Flow<T?> {
|
||||||
|
return preferences.map { it[key] }
|
||||||
|
}
|
||||||
|
|
||||||
|
override operator fun <T> set(key: Preferences.Key<T>, value: T) {
|
||||||
|
scope.launch {
|
||||||
|
dataStore.edit { it[key] = value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
interface Repository
|
||||||
14
core/src/main/java/moe/tabidachi/emulator/data/Rom.kt
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class Rom(
|
||||||
|
@SerialName("name")
|
||||||
|
val name: String? = null,
|
||||||
|
@SerialName("path")
|
||||||
|
val path: String,
|
||||||
|
@SerialName("image_path")
|
||||||
|
val imagePath: String? = null
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class RomListItem(
|
||||||
|
val name: String,
|
||||||
|
val path: File,
|
||||||
|
val imagePath: File
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
interface RomsDataSource : DataSource
|
||||||
|
|
||||||
|
class SdcardRomsDataSource @Inject constructor(
|
||||||
|
|
||||||
|
) : RomsDataSource {
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class StorageConfig(
|
||||||
|
@SerialName("emulator_config_paths")
|
||||||
|
val emulatorConfigPaths: List<String>,
|
||||||
|
@SerialName("assets_path")
|
||||||
|
val assetsPath: String,
|
||||||
|
@SerialName("retroarch_config_path")
|
||||||
|
val retroarchConfigPath: String,
|
||||||
|
@SerialName("bios_path")
|
||||||
|
val biosPath: String,
|
||||||
|
@SerialName("cores_path")
|
||||||
|
val coresPath: String
|
||||||
|
)
|
||||||
20
core/src/main/java/moe/tabidachi/emulator/data/TypedKey.kt
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package moe.tabidachi.emulator.data
|
||||||
|
|
||||||
|
class TypedKey<T>(val name: String, val type: Class<T>) {
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return name.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (javaClass != other?.javaClass) return false
|
||||||
|
|
||||||
|
other as TypedKey<*>
|
||||||
|
|
||||||
|
return name == other.name
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String {
|
||||||
|
return "TypedKey($name, $type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
54
core/src/main/java/moe/tabidachi/emulator/di/CoreModule.kt
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package moe.tabidachi.emulator.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import moe.tabidachi.emulator.common.SharedJson
|
||||||
|
import moe.tabidachi.emulator.data.AssetsState
|
||||||
|
import moe.tabidachi.emulator.data.PermissionState
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CoreModule {
|
||||||
|
@Provides
|
||||||
|
@SdcardPaths
|
||||||
|
@Singleton
|
||||||
|
fun provideSdcardPaths(): MutableStateFlow<Set<String>> {
|
||||||
|
return MutableStateFlow(emptySet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@PermissionStateFlow
|
||||||
|
@Singleton
|
||||||
|
fun providePermissionState(): MutableStateFlow<PermissionState> {
|
||||||
|
return MutableStateFlow(PermissionState.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@AssetsStateFlow
|
||||||
|
@Singleton
|
||||||
|
fun provideAssetsState(): MutableStateFlow<AssetsState> {
|
||||||
|
return MutableStateFlow(AssetsState.None)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideJson(): Json = SharedJson()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class SdcardPaths
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class PermissionStateFlow
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class AssetsStateFlow
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package moe.tabidachi.emulator.di
|
||||||
|
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import javax.inject.Qualifier
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object CoroutineDispatcherModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Dispatcher(EmulatorDispatchers.Default)
|
||||||
|
fun provideDispatcherDefault() = Dispatchers.Default
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
@Dispatcher(EmulatorDispatchers.IO)
|
||||||
|
fun provideDispatcherIO() = Dispatchers.IO
|
||||||
|
}
|
||||||
|
|
||||||
|
@Qualifier
|
||||||
|
@Retention(AnnotationRetention.BINARY)
|
||||||
|
annotation class Dispatcher(val dispatcher: EmulatorDispatchers)
|
||||||
|
|
||||||
|
enum class EmulatorDispatchers {
|
||||||
|
Default, IO
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package moe.tabidachi.emulator.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import moe.tabidachi.emulator.data.DefaultPreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorDataSource
|
||||||
|
import moe.tabidachi.emulator.data.PreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.data.SdcardEmulatorDataSource
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object DataSourceModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun providePreferencesDataSource(
|
||||||
|
@ApplicationContext
|
||||||
|
context: Context
|
||||||
|
): PreferencesDataSource {
|
||||||
|
return DefaultPreferencesDataSource(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmulatorDataSource(
|
||||||
|
@ApplicationContext
|
||||||
|
context: Context,
|
||||||
|
@SdcardPaths
|
||||||
|
sdcardPaths: MutableStateFlow<Set<String>>,
|
||||||
|
json: Json
|
||||||
|
): EmulatorDataSource {
|
||||||
|
return SdcardEmulatorDataSource(sdcardPaths, json)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package moe.tabidachi.emulator.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import moe.tabidachi.emulator.data.DefaultEmulatorRepository
|
||||||
|
import moe.tabidachi.emulator.data.DefaultPreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorDataSource
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorRepository
|
||||||
|
import moe.tabidachi.emulator.data.PreferencesDataSource
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object RepositoryModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEmulatorRepository(
|
||||||
|
@ApplicationContext
|
||||||
|
context: Context,
|
||||||
|
emulatorDataSource: EmulatorDataSource
|
||||||
|
): EmulatorRepository {
|
||||||
|
return DefaultEmulatorRepository(emulatorDataSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import moe.tabidachi.emulator.common.Permissions
|
||||||
|
import moe.tabidachi.emulator.common.ktx.TAG
|
||||||
|
import moe.tabidachi.emulator.data.PermissionState
|
||||||
|
import moe.tabidachi.emulator.di.PermissionStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class CheckPermissionUseCase @Inject constructor(
|
||||||
|
@ApplicationContext
|
||||||
|
private val context: Context,
|
||||||
|
@PermissionStateFlow
|
||||||
|
private val permissionState: MutableStateFlow<PermissionState>
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Boolean {
|
||||||
|
val granted = Permissions.map {
|
||||||
|
it to context.checkSelfPermission(it)
|
||||||
|
}.onEach {
|
||||||
|
Log.d(TAG, "${it.first}=${it.second == PackageManager.PERMISSION_GRANTED}")
|
||||||
|
}.all {
|
||||||
|
it.second == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
permissionState.value = if (granted) PermissionState.Granted else PermissionState.Denied
|
||||||
|
return granted
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import moe.tabidachi.emulator.common.AssetsExtracted
|
||||||
|
import moe.tabidachi.emulator.common.ktx.unzip
|
||||||
|
import moe.tabidachi.emulator.data.PreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.di.Dispatcher
|
||||||
|
import moe.tabidachi.emulator.di.EmulatorDispatchers
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ExtractAssetsUseCase @Inject constructor(
|
||||||
|
@ApplicationContext
|
||||||
|
private val context: Context,
|
||||||
|
@Dispatcher(EmulatorDispatchers.IO)
|
||||||
|
private val dispatcher: CoroutineDispatcher,
|
||||||
|
private val preferences: PreferencesDataSource
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
file: File,
|
||||||
|
onSuccess: () -> Unit = {},
|
||||||
|
onProgress: (Float) -> Unit = {}
|
||||||
|
) = withContext(dispatcher) {
|
||||||
|
file.unzip(
|
||||||
|
extractToPath = ContextCompat.getDataDir(context)!!,
|
||||||
|
onSuccess = {
|
||||||
|
onSuccess()
|
||||||
|
preferences[AssetsExtracted] = true
|
||||||
|
},
|
||||||
|
onProgress = onProgress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorRepository
|
||||||
|
import java.io.File
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetAssetsFileUseCase @Inject constructor(
|
||||||
|
private val emulatorRepository: EmulatorRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<File> {
|
||||||
|
return emulatorRepository.getAssetsFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import moe.tabidachi.emulator.common.AssetsExtracted
|
||||||
|
import moe.tabidachi.emulator.data.AssetsState
|
||||||
|
import moe.tabidachi.emulator.data.PreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.di.AssetsStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetAssetsStateUseCase @Inject constructor(
|
||||||
|
private val preferences: PreferencesDataSource,
|
||||||
|
@AssetsStateFlow
|
||||||
|
private val assetsState: MutableStateFlow<AssetsState>
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(): AssetsState {
|
||||||
|
val assetsExtracted = preferences[AssetsExtracted].first()
|
||||||
|
val state = when (assetsExtracted) {
|
||||||
|
true -> AssetsState.Extracted
|
||||||
|
else -> AssetsState.NotExtracted
|
||||||
|
}
|
||||||
|
assetsState.value = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorListItem
|
||||||
|
import moe.tabidachi.emulator.data.EmulatorRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetEmulatorListUseCase @Inject constructor(
|
||||||
|
private val emulatorRepository: EmulatorRepository
|
||||||
|
) {
|
||||||
|
operator fun invoke(): Flow<List<EmulatorListItem>> {
|
||||||
|
return emulatorRepository.getEmulatorList()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.storage.StorageManager
|
||||||
|
import android.os.storage.StorageVolume
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import moe.tabidachi.emulator.common.ktx.TAG
|
||||||
|
import moe.tabidachi.emulator.di.SdcardPaths
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class GetSdcardPathsUseCase @Inject constructor(
|
||||||
|
@ApplicationContext
|
||||||
|
private val context: Context,
|
||||||
|
@SdcardPaths
|
||||||
|
private val sdcardPaths: MutableStateFlow<Set<String>>
|
||||||
|
) {
|
||||||
|
operator fun invoke(): List<String> {
|
||||||
|
return kotlin.runCatching {
|
||||||
|
val storageManager = context.getSystemService<StorageManager>() ?: return emptyList()
|
||||||
|
val clazz = Class.forName("android.os.storage.StorageVolume")
|
||||||
|
val getVolumeListMethod = storageManager.javaClass.getMethod("getVolumeList")
|
||||||
|
val getPathMethod = clazz.getMethod("getPath")
|
||||||
|
val volumes = getVolumeListMethod.invoke(storageManager) as Array<StorageVolume>
|
||||||
|
volumes.filter {
|
||||||
|
it.isRemovable
|
||||||
|
}.map {
|
||||||
|
getPathMethod.invoke(it) as String
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "读取sd卡失败", it)
|
||||||
|
}.onSuccess {
|
||||||
|
Log.d(TAG, "读取sd卡成功$it")
|
||||||
|
sdcardPaths.value = it.toSet()
|
||||||
|
}.getOrDefault(emptyList())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package moe.tabidachi.emulator.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import moe.tabidachi.emulator.common.AssetsExtracted
|
||||||
|
import moe.tabidachi.emulator.data.AssetsState
|
||||||
|
import moe.tabidachi.emulator.data.PreferencesDataSource
|
||||||
|
import moe.tabidachi.emulator.di.AssetsStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class UpdateAssetsStateUseCase @Inject constructor(
|
||||||
|
private val preferences: PreferencesDataSource,
|
||||||
|
@AssetsStateFlow
|
||||||
|
private val assetsState: MutableStateFlow<AssetsState>
|
||||||
|
) {
|
||||||
|
operator fun invoke(state: AssetsState) {
|
||||||
|
assetsState.value = state
|
||||||
|
preferences[AssetsExtracted] = state == AssetsState.Extracted
|
||||||
|
}
|
||||||
|
}
|
||||||
16
core/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
package moe.tabidachi.emulator
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,20 @@ composeBom = "2025.01.01"
|
|||||||
tvMaterial = "1.1.0-alpha01"
|
tvMaterial = "1.1.0-alpha01"
|
||||||
lifecycleRuntimeKtx = "2.8.7"
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
activityCompose = "1.10.0"
|
activityCompose = "1.10.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.2.1"
|
||||||
|
espressoCore = "3.6.1"
|
||||||
|
material = "1.12.0"
|
||||||
|
ktor = "3.0.3"
|
||||||
|
hilt = "2.55"
|
||||||
|
ksp = "2.1.10-1.0.29"
|
||||||
|
hilt-navigation-compose = "1.2.0"
|
||||||
|
compose-navigation = "2.8.6"
|
||||||
|
kotlin-serialization = "1.8.0"
|
||||||
|
datastore = "1.1.2"
|
||||||
|
coil3 = "3.0.4"
|
||||||
|
work = "2.10.0"
|
||||||
|
androidx-room = "2.6.1"
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
@@ -21,8 +34,40 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
|
|||||||
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
androidx-tv-material = { group = "androidx.tv", name = "tv-material", version.ref = "tvMaterial" }
|
||||||
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
junit = { group = "junit", name = "junit", version.ref = "junit" }
|
||||||
|
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
|
||||||
|
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
# ktor
|
||||||
|
ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" }
|
||||||
|
ktor-client-cio = { group = "io.ktor", name = "ktor-client-cio", version.ref = "ktor" }
|
||||||
|
ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" }
|
||||||
|
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
ktor-client-logging = { group = "io.ktor", name = "ktor-client-logging", version.ref = "ktor" }
|
||||||
|
# hilt
|
||||||
|
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
|
||||||
|
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
|
||||||
|
# navigation
|
||||||
|
compose-navigation = { group = "androidx.navigation", name = "navigation-compose", version.ref = "compose-navigation" }
|
||||||
|
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hilt-navigation-compose" }
|
||||||
|
kotlin-serialization = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
||||||
|
# datastore
|
||||||
|
datastore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
|
# coil
|
||||||
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil3" }
|
||||||
|
coil-network-ktor3 = { group = "io.coil-kt.coil3", name = "coil-network-ktor3", version.ref = "coil3" }
|
||||||
|
# workmanager
|
||||||
|
work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
|
||||||
|
# room
|
||||||
|
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
|
||||||
|
androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
|
||||||
[plugins]
|
[plugins]
|
||||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
|||||||
4
keystore.properties
Executable file
@@ -0,0 +1,4 @@
|
|||||||
|
signing.storeFile=../platform.jks
|
||||||
|
signing.storePassword=android
|
||||||
|
signing.keyAlias=platform
|
||||||
|
signing.keyPassword=android
|
||||||
BIN
platform.jks
Executable file
@@ -20,5 +20,6 @@ dependencyResolutionManagement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rootProject.name = "Emulator"
|
rootProject.name = "Emulator"
|
||||||
include(":app")
|
include(":app-default")
|
||||||
|
include(":core")
|
||||||
|
include(":ui")
|
||||||
|
|||||||
1
ui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
76
ui/build.gradle.kts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.library)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.kotlin.serialization)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "moe.tabidachi.emulator"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = 23
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
consumerProguardFiles("consumer-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
api(libs.androidx.core.ktx)
|
||||||
|
api(libs.androidx.appcompat)
|
||||||
|
api(libs.material)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
api(libs.androidx.ui)
|
||||||
|
api(libs.androidx.ui.graphics)
|
||||||
|
api(libs.androidx.ui.tooling.preview)
|
||||||
|
api(libs.androidx.tv.material)
|
||||||
|
api(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
api(libs.androidx.activity.compose)
|
||||||
|
// hilt
|
||||||
|
api(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
// navigation
|
||||||
|
api(libs.hilt.navigation.compose)
|
||||||
|
api(libs.compose.navigation)
|
||||||
|
api(libs.kotlin.serialization)
|
||||||
|
api(libs.datastore)
|
||||||
|
// ktor
|
||||||
|
api(libs.ktor.client.core)
|
||||||
|
api(libs.ktor.client.cio)
|
||||||
|
api(libs.ktor.client.content.negotiation)
|
||||||
|
api(libs.ktor.serialization.kotlinx.json)
|
||||||
|
api(libs.ktor.client.logging)
|
||||||
|
// coil
|
||||||
|
api(libs.coil.compose)
|
||||||
|
api(libs.coil.network.ktor3)
|
||||||
|
// work
|
||||||
|
api(libs.work.runtime.ktx)
|
||||||
|
// room
|
||||||
|
api(libs.androidx.room.ktx)
|
||||||
|
ksp(libs.androidx.room.compiler)
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
}
|
||||||
0
ui/consumer-rules.pro
Normal file
21
ui/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package moe.tabidachi.emulator
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("moe.tabidachi.emulator.test", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ui/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import moe.tabidachi.emulator.R
|
||||||
|
import moe.tabidachi.emulator.ui.components.LinearProgressBar
|
||||||
|
import moe.tabidachi.emulator.ui.components.TvDialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AssetsExtractDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
progress: Float
|
||||||
|
) {
|
||||||
|
if (visible) TvDialog(
|
||||||
|
onDismissRequest = {}
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(16.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(text = stringResource(R.string.assets_loading_message))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(text = "${(progress * 100).toInt()}%")
|
||||||
|
}
|
||||||
|
LinearProgressBar(progress, modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.AnimationSpec
|
||||||
|
import androidx.compose.animation.core.CubicBezierEasing
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.gestures.BringIntoViewSpec
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun rememberPivotBringIntoViewSpec(
|
||||||
|
parentFraction: Float = 0.5f,
|
||||||
|
childFraction: Float = 0.5f
|
||||||
|
) = remember {
|
||||||
|
PivotBringIntoViewSpec(
|
||||||
|
parentFraction,
|
||||||
|
childFraction
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
private val PivotBringIntoViewSpecSaver = Saver<PivotBringIntoViewSpec, Pair<Float, Float>>(
|
||||||
|
save = {
|
||||||
|
it.parentFraction to it.childFraction
|
||||||
|
},
|
||||||
|
restore = { PivotBringIntoViewSpec(it.first, it.second) }
|
||||||
|
)
|
||||||
|
|
||||||
|
@ExperimentalFoundationApi
|
||||||
|
class PivotBringIntoViewSpec internal constructor(
|
||||||
|
val parentFraction: Float = 0.5f,
|
||||||
|
val childFraction: Float = 0.5f
|
||||||
|
) : BringIntoViewSpec {
|
||||||
|
override val scrollAnimationSpec: AnimationSpec<Float> = tween(
|
||||||
|
durationMillis = 125,
|
||||||
|
easing = CubicBezierEasing(0.25f, 0.1f, .25f, 1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun calculateScrollDistance(
|
||||||
|
offset: Float,
|
||||||
|
size: Float,
|
||||||
|
containerSize: Float
|
||||||
|
): Float {
|
||||||
|
val leadingEdgeOfItemRequestingFocus = offset
|
||||||
|
val trailingEdgeOfItemRequestingFocus = offset + size
|
||||||
|
|
||||||
|
val sizeOfItemRequestingFocus =
|
||||||
|
abs(trailingEdgeOfItemRequestingFocus - leadingEdgeOfItemRequestingFocus)
|
||||||
|
val childSmallerThanParent = sizeOfItemRequestingFocus <= containerSize
|
||||||
|
val initialTargetForLeadingEdge =
|
||||||
|
parentFraction * containerSize -
|
||||||
|
(childFraction * sizeOfItemRequestingFocus)
|
||||||
|
val spaceAvailableToShowItem = containerSize - initialTargetForLeadingEdge
|
||||||
|
|
||||||
|
val targetForLeadingEdge =
|
||||||
|
if (childSmallerThanParent &&
|
||||||
|
spaceAvailableToShowItem < sizeOfItemRequestingFocus
|
||||||
|
) {
|
||||||
|
containerSize - sizeOfItemRequestingFocus
|
||||||
|
} else {
|
||||||
|
initialTargetForLeadingEdge
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadingEdgeOfItemRequestingFocus - targetForLeadingEdge
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MediaChangeListener(
|
||||||
|
onMediaChange: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
intent.action?.let(onMediaChange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(Intent.ACTION_MEDIA_MOUNTED)
|
||||||
|
addAction(Intent.ACTION_MEDIA_UNMOUNTED)
|
||||||
|
addAction(Intent.ACTION_MEDIA_EJECT)
|
||||||
|
addAction(Intent.ACTION_MEDIA_REMOVED)
|
||||||
|
addDataScheme("file")
|
||||||
|
}
|
||||||
|
context.registerReceiver(receiver, filter)
|
||||||
|
onDispose {
|
||||||
|
context.unregisterReceiver(receiver)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.WideButton
|
||||||
|
import moe.tabidachi.emulator.R
|
||||||
|
import moe.tabidachi.emulator.ui.components.TvAlertDialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NoSdcardDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
if (visible) {
|
||||||
|
TvAlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = {
|
||||||
|
Text(text = stringResource(R.string.no_sdcard_dialog_title))
|
||||||
|
}, text = {
|
||||||
|
Text(text = stringResource(R.string.no_sdcard_dialog_message))
|
||||||
|
}, confirmButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.dialog_confirm))
|
||||||
|
}
|
||||||
|
}, dismissButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.dialog_cancel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.WideButton
|
||||||
|
import moe.tabidachi.emulator.R
|
||||||
|
import moe.tabidachi.emulator.ui.components.TvAlertDialog
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PermissionDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
permissions: Array<String>,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onGranted: () -> Unit,
|
||||||
|
onDenied: () -> Unit
|
||||||
|
) {
|
||||||
|
val launcher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
onResult = {
|
||||||
|
if (it.all { it.value }) {
|
||||||
|
onGranted()
|
||||||
|
onDismissRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (visible) {
|
||||||
|
TvAlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
title = {
|
||||||
|
Text(text = stringResource(R.string.permission_dialog_title))
|
||||||
|
}, text = {
|
||||||
|
Text(text = stringResource(R.string.permission_dialog_message))
|
||||||
|
}, confirmButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
launcher.launch(permissions)
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.permission_dialog_allow))
|
||||||
|
}
|
||||||
|
}, dismissButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
onDismissRequest()
|
||||||
|
onDenied()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = stringResource(R.string.permission_dialog_deny))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import moe.tabidachi.emulator.ui.theme.EmulatorTheme
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PreviewSurface(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable BoxScope.() -> Unit
|
||||||
|
) {
|
||||||
|
EmulatorTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.common
|
||||||
|
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
import android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
|
||||||
|
@Preview(device = "id:tv_1080p", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_TELEVISION)
|
||||||
|
@Preview(device = "id:tv_1080p", uiMode = UI_MODE_NIGHT_NO or UI_MODE_TYPE_TELEVISION)
|
||||||
|
annotation class TvPreview
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进度条
|
||||||
|
*
|
||||||
|
* @param progress 进度,范围[0.0, 1.0]
|
||||||
|
* @param backgroundColor 进度条背景颜色
|
||||||
|
* @param progressColor 进度条前景颜色
|
||||||
|
* @param strokeWidth 进度条宽度
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LinearProgressBar(
|
||||||
|
progress: Float,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
progressColor: Color = MaterialTheme.colorScheme.primary,
|
||||||
|
strokeWidth: Dp = 8.dp
|
||||||
|
) {
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.height(strokeWidth)
|
||||||
|
.padding(horizontal = strokeWidth / 2)
|
||||||
|
) {
|
||||||
|
// 绘制背景
|
||||||
|
drawLine(
|
||||||
|
color = backgroundColor,
|
||||||
|
start = Offset(0f, size.height / 2),
|
||||||
|
end = Offset(size.width, size.height / 2),
|
||||||
|
strokeWidth = strokeWidth.toPx(),
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
|
||||||
|
// 绘制进度
|
||||||
|
drawLine(
|
||||||
|
color = progressColor,
|
||||||
|
start = Offset(0f, size.height / 2),
|
||||||
|
end = Offset(size.width * progress, size.height / 2),
|
||||||
|
strokeWidth = strokeWidth.toPx(),
|
||||||
|
cap = StrokeCap.Round
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package moe.tabidachi.emulator.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.window.Dialog
|
||||||
|
import androidx.compose.ui.window.DialogProperties
|
||||||
|
import androidx.tv.material3.MaterialTheme
|
||||||
|
import androidx.tv.material3.ProvideTextStyle
|
||||||
|
import androidx.tv.material3.Surface
|
||||||
|
import androidx.tv.material3.Text
|
||||||
|
import androidx.tv.material3.WideButton
|
||||||
|
import moe.tabidachi.emulator.ui.common.PreviewSurface
|
||||||
|
import moe.tabidachi.emulator.ui.common.TvPreview
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TvDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
content: @Composable BoxScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Dialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
properties = DialogProperties(usePlatformDefaultWidth = false)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = TvDialogShape,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(16.dp),
|
||||||
|
content = content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TvAlertDialog(
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
title: @Composable (() -> Unit)? = {},
|
||||||
|
text: @Composable (() -> Unit)? = {},
|
||||||
|
confirmButton: @Composable (() -> Unit)? = {},
|
||||||
|
dismissButton: @Composable (() -> Unit)? = {}
|
||||||
|
) {
|
||||||
|
TvDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
content = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
ProvideTextStyle(MaterialTheme.typography.titleLarge) {
|
||||||
|
title?.invoke()
|
||||||
|
}
|
||||||
|
ProvideTextStyle(MaterialTheme.typography.bodyLarge) {
|
||||||
|
text?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
modifier = Modifier
|
||||||
|
) {
|
||||||
|
confirmButton?.invoke()
|
||||||
|
dismissButton?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@TvPreview
|
||||||
|
@Composable
|
||||||
|
private fun TvDialogPreview() {
|
||||||
|
PreviewSurface {
|
||||||
|
TvAlertDialog(
|
||||||
|
onDismissRequest = {
|
||||||
|
|
||||||
|
}, title = {
|
||||||
|
Text(text = "Hello World")
|
||||||
|
}, text = {
|
||||||
|
Text(text = "High level element that displays text and provides semantics / accessibility information.")
|
||||||
|
}, confirmButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = "确认")
|
||||||
|
}
|
||||||
|
}, dismissButton = {
|
||||||
|
WideButton(
|
||||||
|
onClick = {
|
||||||
|
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Text(text = "取消")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val TvDialogShape: RoundedCornerShape = RoundedCornerShape(14.dp)
|
||||||
13
ui/src/main/res/values-zh-rCN/strings.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="permission_dialog_title">存储权限请求</string>
|
||||||
|
<string name="permission_dialog_message">我们需要访问您的存储空间以保存和读取文件。请授予存储权限。</string>
|
||||||
|
<string name="permission_dialog_allow">允许</string>
|
||||||
|
<string name="permission_dialog_deny">拒绝</string>
|
||||||
|
<string name="assets_loading_message">加载资源…</string>
|
||||||
|
<string name="no_sdcard_dialog_title">请插入游戏卡</string>
|
||||||
|
<string name="no_sdcard_dialog_message">未检测到游戏卡,我们将无法为您提供服务。</string>
|
||||||
|
<string name="dialog_confirm">确定</string>
|
||||||
|
<string name="dialog_cancel">取消</string>
|
||||||
|
<string name="game_size">%s GAMES</string>
|
||||||
|
</resources>
|
||||||
13
ui/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="permission_dialog_title">Store permission requests</string>
|
||||||
|
<string name="permission_dialog_message">We need to access your storage space to save and read files. Please grant storage permissions.</string>
|
||||||
|
<string name="permission_dialog_allow">Allow</string>
|
||||||
|
<string name="permission_dialog_deny">Deny</string>
|
||||||
|
<string name="assets_loading_message">Loading resources…</string>
|
||||||
|
<string name="no_sdcard_dialog_title">Please insert the game card</string>
|
||||||
|
<string name="no_sdcard_dialog_message">We will not be able to provide you with the game card not detected.</string>
|
||||||
|
<string name="dialog_confirm">Confirm</string>
|
||||||
|
<string name="dialog_cancel">Cancel</string>
|
||||||
|
<string name="game_size">%s GAMES</string>
|
||||||
|
</resources>
|
||||||
17
ui/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package moe.tabidachi.emulator
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Example local unit test, which will execute on the development machine (host).
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
class ExampleUnitTest {
|
||||||
|
@Test
|
||||||
|
fun addition_isCorrect() {
|
||||||
|
assertEquals(4, 2 + 2)
|
||||||
|
}
|
||||||
|
}
|
||||||