基本界面

This commit is contained in:
2025-02-11 17:20:58 +08:00
parent 235f5cb281
commit 9e9054d98a
87 changed files with 2302 additions and 80 deletions

View 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)
}

View File

@@ -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"

View 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()
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
)
}
}
}

View File

@@ -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

View File

@@ -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()
)
}
}

View File

@@ -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 = {},
)

View File

@@ -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))
}

View File

@@ -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()
)
}
}

View File

@@ -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)
}
}

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -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)
}

View File

@@ -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
View File

@@ -0,0 +1 @@
/build

69
core/build.gradle.kts Normal file
View 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
View File

21
core/proguard-rules.pro vendored Normal file
View 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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -0,0 +1,7 @@
package moe.tabidachi.emulator.common
class GameLauncher(
) {
}

View 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
}

View File

@@ -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
)

View File

@@ -0,0 +1,5 @@
package moe.tabidachi.emulator.common
import androidx.datastore.preferences.core.booleanPreferencesKey
val AssetsExtracted = booleanPreferencesKey("assets_extracted")

View File

@@ -0,0 +1,3 @@
package moe.tabidachi.emulator.common.ktx
val Any.TAG: String get() = this::class.java.simpleName

View 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
}

View 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()
}

View File

@@ -0,0 +1,5 @@
package moe.tabidachi.emulator.data
enum class AssetsState {
None, Extracted, NotExtracted
}

View File

@@ -0,0 +1,3 @@
package moe.tabidachi.emulator.data
interface DataSource

View File

@@ -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()
)

View File

@@ -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 ?: "")
)
}
}
}
}

View File

@@ -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,
)

View File

@@ -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()
}
}

View File

@@ -0,0 +1,5 @@
package moe.tabidachi.emulator.data
enum class PermissionState {
None, Granted, Denied
}

View File

@@ -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 }
}
}
}

View File

@@ -0,0 +1,3 @@
package moe.tabidachi.emulator.data
interface Repository

View 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
)

View File

@@ -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
)

View File

@@ -0,0 +1,10 @@
package moe.tabidachi.emulator.data
import javax.inject.Inject
interface RomsDataSource : DataSource
class SdcardRomsDataSource @Inject constructor(
) : RomsDataSource {
}

View File

@@ -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
)

View 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)"
}
}

View 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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}

View File

@@ -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
)
}
}

View File

@@ -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()
}
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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())
}
}

View File

@@ -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
}
}

View 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)
}
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
signing.storeFile=../platform.jks
signing.storePassword=android
signing.keyAlias=platform
signing.keyPassword=android

BIN
platform.jks Executable file

Binary file not shown.

View File

@@ -20,5 +20,6 @@ dependencyResolutionManagement {
}
rootProject.name = "Emulator"
include(":app")
include(":app-default")
include(":core")
include(":ui")

1
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

76
ui/build.gradle.kts Normal file
View 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
View File

21
ui/proguard-rules.pro vendored Normal file
View 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

View File

@@ -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)
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>

View File

@@ -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())
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}
)
}
}

View File

@@ -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))
}
}
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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

View File

@@ -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
)
}
}

View File

@@ -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)

View 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>

View 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>

View 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)
}
}