基本界面
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"
|
||||
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
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false" />
|
||||
@@ -10,6 +13,7 @@
|
||||
android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".Emulator"
|
||||
android:allowBackup="true"
|
||||
android:banner="@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.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
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
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
EmulatorTheme {
|
||||
EmulatorTheme(isInDarkTheme = true) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
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.kotlin.android) 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"
|
||||
lifecycleRuntimeKtx = "2.8.7"
|
||||
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]
|
||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||
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-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" }
|
||||
|
||||
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]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
kotlin-android = { id = "org.jetbrains.kotlin.android", 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"
|
||||
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)
|
||||
}
|
||||
}
|
||||