diff --git a/app/.gitignore b/app-default/.gitignore similarity index 100% rename from app/.gitignore rename to app-default/.gitignore diff --git a/app-default/build.gradle.kts b/app-default/build.gradle.kts new file mode 100644 index 0000000..2ad5fc6 --- /dev/null +++ b/app-default/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app-default/proguard-rules.pro similarity index 100% rename from app/proguard-rules.pro rename to app-default/proguard-rules.pro diff --git a/app/src/main/AndroidManifest.xml b/app-default/src/main/AndroidManifest.xml similarity index 84% rename from app/src/main/AndroidManifest.xml rename to app-default/src/main/AndroidManifest.xml index 246d799..1d2840d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app-default/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + + + @@ -10,6 +13,7 @@ android:required="false" /> 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") + } + ) + } + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt new file mode 100644 index 0000000..dcb7568 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeRoute.kt @@ -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 { + 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 \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeScreen.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeScreen.kt new file mode 100644 index 0000000..df6eab2 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeScreen.kt @@ -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(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() + ) + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt new file mode 100644 index 0000000..35b9595 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/home/HomeViewModel.kt @@ -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, + @PermissionStateFlow + private val permissionState: MutableStateFlow, + @SdcardPaths + private val sdcardPaths: MutableStateFlow>, + 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 -> + _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 = 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 = {}, +) diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt new file mode 100644 index 0000000..7a98a70 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsRoute.kt @@ -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 { + 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)) +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt new file mode 100644 index 0000000..2278235 --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsScreen.kt @@ -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(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() + ) + } +} \ No newline at end of file diff --git a/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt new file mode 100644 index 0000000..703f91b --- /dev/null +++ b/app-default/src/main/java/moe/tabidachi/emulator/ui/roms/RomsViewModel.kt @@ -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().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 = emptyList(), +) + +data class RomsActions( + val onClick: () -> Unit = {} +) + +class GetRomsUseCase @Inject constructor( + private val emulatorDataSource: EmulatorDataSource +) { + operator fun invoke(romId: String): Flow> { + return emulatorDataSource.getRomListByEmulatorId(romId) + } +} \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-hdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-hdpi/ic_launcher.webp rename to app-default/src/main/res/mipmap-hdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-mdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-mdpi/ic_launcher.webp rename to app-default/src/main/res/mipmap-mdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xhdpi/ic_launcher.webp rename to app-default/src/main/res/mipmap-xhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxhdpi/ic_launcher.webp rename to app-default/src/main/res/mipmap-xxhdpi/ic_launcher.webp diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp similarity index 100% rename from app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp rename to app-default/src/main/res/mipmap-xxxhdpi/ic_launcher.webp diff --git a/app/src/main/res/values/strings.xml b/app-default/src/main/res/values/strings.xml similarity index 100% rename from app/src/main/res/values/strings.xml rename to app-default/src/main/res/values/strings.xml diff --git a/app/src/main/res/values/themes.xml b/app-default/src/main/res/values/themes.xml similarity index 100% rename from app/src/main/res/values/themes.xml rename to app-default/src/main/res/values/themes.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts deleted file mode 100644 index ad80fc6..0000000 --- a/app/build.gradle.kts +++ /dev/null @@ -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) -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 952b930..e545b6d 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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 } \ No newline at end of file diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/core/build.gradle.kts b/core/build.gradle.kts new file mode 100644 index 0000000..c1f2f53 --- /dev/null +++ b/core/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/core/consumer-rules.pro b/core/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/core/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/core/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt b/core/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ff80321 --- /dev/null +++ b/core/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/core/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt new file mode 100644 index 0000000..131925e --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/GameLauncher.kt @@ -0,0 +1,7 @@ +package moe.tabidachi.emulator.common + +class GameLauncher( + +) { + +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/Json.kt b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt new file mode 100644 index 0000000..8183eba --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/Json.kt @@ -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 +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/Permissions.kt b/core/src/main/java/moe/tabidachi/emulator/common/Permissions.kt new file mode 100644 index 0000000..6ef8963 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/Permissions.kt @@ -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 +) \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/PreferencesKeys.kt b/core/src/main/java/moe/tabidachi/emulator/common/PreferencesKeys.kt new file mode 100644 index 0000000..3325482 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/PreferencesKeys.kt @@ -0,0 +1,5 @@ +package moe.tabidachi.emulator.common + +import androidx.datastore.preferences.core.booleanPreferencesKey + +val AssetsExtracted = booleanPreferencesKey("assets_extracted") diff --git a/core/src/main/java/moe/tabidachi/emulator/common/ktx/Any.kt b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Any.kt new file mode 100644 index 0000000..16bf6db --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Any.kt @@ -0,0 +1,3 @@ +package moe.tabidachi.emulator.common.ktx + +val Any.TAG: String get() = this::class.java.simpleName \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/ktx/File.kt b/core/src/main/java/moe/tabidachi/emulator/common/ktx/File.kt new file mode 100644 index 0000000..f2c3c00 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/ktx/File.kt @@ -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 +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/common/ktx/Int.kt b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Int.kt new file mode 100644 index 0000000..654cc0a --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/common/ktx/Int.kt @@ -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() + } \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/AssetsState.kt b/core/src/main/java/moe/tabidachi/emulator/data/AssetsState.kt new file mode 100644 index 0000000..0d1039c --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/AssetsState.kt @@ -0,0 +1,5 @@ +package moe.tabidachi.emulator.data + +enum class AssetsState { + None, Extracted, NotExtracted +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/DataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/DataSource.kt new file mode 100644 index 0000000..2f31c49 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/DataSource.kt @@ -0,0 +1,3 @@ +package moe.tabidachi.emulator.data + +interface DataSource \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt new file mode 100644 index 0000000..63d7ea0 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorConfig.kt @@ -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 = emptyList(), + @SerialName("icon_path") + val iconPath: String? = null, + @SerialName("background_path") + val backgroundPath: String? = null, + @SerialName("roms_paths") + val romsPaths: List = emptyList(), + @SerialName("images_paths") + val imagesPaths: List = emptyList(), + @SerialName("roms_scan_mode") + val romsScanMode: Int = 0, + @SerialName("roms_size") + val romsSize: Int = 0, + @SerialName("roms") + val roms: List = emptyList() +) diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt new file mode 100644 index 0000000..447ad25 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorDataSource.kt @@ -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>> + fun getAssetsFile(): Flow + fun getEmulatorList(): Flow> + fun getRomListByEmulatorId(id: String): Flow> +} + +class SdcardEmulatorDataSource @Inject constructor( + @SdcardPaths + private val sdcardPaths: MutableStateFlow>, + private val json: Json +) : EmulatorDataSource { + override val storageConfigList: Flow>> = + sdcardPaths/*.distinctUntilChanged { old, new -> + old.containsAll(new) + }*/.map { paths: Set -> + paths.map { root: String -> + root to File(root, "config.json") + }.mapNotNull { (root, file) -> + runCatching { + root to json.decodeFromString(file.readText()) + }.onFailure { + Log.e(TAG, it.message, it) + }.getOrNull() + } + } + + override fun getAssetsFile(): Flow { + return storageConfigList.mapNotNull { + it.map { (root, storageConfig) -> + File(root, storageConfig.assetsPath) + }.firstOrNull { + it.exists() + } + } + } + + private fun getEmulatorConfigList(): Flow>> { + 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(file.readText()) + }.onFailure { + Log.e(TAG, "$file 反序列化失败", it) + }.getOrNull() + } + }.flatten() + } + } + + override fun getEmulatorList(): Flow> { + 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> { + 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 ?: "") + ) + } + } + } +} diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt new file mode 100644 index 0000000..300c823 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorListItem.kt @@ -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, +) \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt new file mode 100644 index 0000000..f08aa98 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/EmulatorRepository.kt @@ -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 + fun getEmulatorList(): Flow> +} + +class DefaultEmulatorRepository @Inject constructor( + private val emulatorDataSource: EmulatorDataSource +) : EmulatorRepository { + override fun getAssetsFile(): Flow { + return emulatorDataSource.getAssetsFile() + } + + override fun getEmulatorList(): Flow> { + return emulatorDataSource.getEmulatorList() + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/PermissionState.kt b/core/src/main/java/moe/tabidachi/emulator/data/PermissionState.kt new file mode 100644 index 0000000..f47b270 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/PermissionState.kt @@ -0,0 +1,5 @@ +package moe.tabidachi.emulator.data + +enum class PermissionState { + None, Granted, Denied +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/PreferencesDataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/PreferencesDataSource.kt new file mode 100644 index 0000000..d7de92b --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/PreferencesDataSource.kt @@ -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 get(key: Preferences.Key): Flow + operator fun set(key: Preferences.Key, 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 = context.dataStore + private val preferences: Flow = dataStore.data + + override operator fun get(key: Preferences.Key): Flow { + return preferences.map { it[key] } + } + + override operator fun set(key: Preferences.Key, value: T) { + scope.launch { + dataStore.edit { it[key] = value } + } + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/Repository.kt b/core/src/main/java/moe/tabidachi/emulator/data/Repository.kt new file mode 100644 index 0000000..84712f2 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/Repository.kt @@ -0,0 +1,3 @@ +package moe.tabidachi.emulator.data + +interface Repository \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt new file mode 100644 index 0000000..f151097 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/Rom.kt @@ -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 +) diff --git a/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt new file mode 100644 index 0000000..eae6fed --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/RomListItem.kt @@ -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 +) diff --git a/core/src/main/java/moe/tabidachi/emulator/data/RomsDataSource.kt b/core/src/main/java/moe/tabidachi/emulator/data/RomsDataSource.kt new file mode 100644 index 0000000..6d3d6cd --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/RomsDataSource.kt @@ -0,0 +1,10 @@ +package moe.tabidachi.emulator.data + +import javax.inject.Inject + +interface RomsDataSource : DataSource + +class SdcardRomsDataSource @Inject constructor( + +) : RomsDataSource { +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt new file mode 100644 index 0000000..6c726d6 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/StorageConfig.kt @@ -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, + @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 +) \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/data/TypedKey.kt b/core/src/main/java/moe/tabidachi/emulator/data/TypedKey.kt new file mode 100644 index 0000000..0e7d521 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/data/TypedKey.kt @@ -0,0 +1,20 @@ +package moe.tabidachi.emulator.data + +class TypedKey(val name: String, val type: Class) { + 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)" + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/di/CoreModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/CoreModule.kt new file mode 100644 index 0000000..233498e --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/di/CoreModule.kt @@ -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> { + return MutableStateFlow(emptySet()) + } + + @Provides + @PermissionStateFlow + @Singleton + fun providePermissionState(): MutableStateFlow { + return MutableStateFlow(PermissionState.None) + } + + @Provides + @AssetsStateFlow + @Singleton + fun provideAssetsState(): MutableStateFlow { + 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 \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/di/CoroutineDispatcherModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/CoroutineDispatcherModule.kt new file mode 100644 index 0000000..ee16db1 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/di/CoroutineDispatcherModule.kt @@ -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 +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/di/DataSourceModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/DataSourceModule.kt new file mode 100644 index 0000000..b324267 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/di/DataSourceModule.kt @@ -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>, + json: Json + ): EmulatorDataSource { + return SdcardEmulatorDataSource(sdcardPaths, json) + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt new file mode 100644 index 0000000..eccd079 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/di/RepositoryModule.kt @@ -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) + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/CheckPermissionUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/CheckPermissionUseCase.kt new file mode 100644 index 0000000..ea0d658 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/CheckPermissionUseCase.kt @@ -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 +) { + 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 + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/ExtractAssetsUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/ExtractAssetsUseCase.kt new file mode 100644 index 0000000..f1f3ced --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/ExtractAssetsUseCase.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt new file mode 100644 index 0000000..4fbb59a --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsFileUseCase.kt @@ -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 { + return emulatorRepository.getAssetsFile() + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsStateUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsStateUseCase.kt new file mode 100644 index 0000000..4d60a02 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetAssetsStateUseCase.kt @@ -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 +) { + 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 + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt new file mode 100644 index 0000000..ba930b4 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetEmulatorListUseCase.kt @@ -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> { + return emulatorRepository.getEmulatorList() + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt new file mode 100644 index 0000000..ab99cb3 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/GetSdcardPathsUseCase.kt @@ -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> +) { + operator fun invoke(): List { + return kotlin.runCatching { + val storageManager = context.getSystemService() ?: 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 + 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()) + } +} \ No newline at end of file diff --git a/core/src/main/java/moe/tabidachi/emulator/domain/UpdateAssetsStateUseCase.kt b/core/src/main/java/moe/tabidachi/emulator/domain/UpdateAssetsStateUseCase.kt new file mode 100644 index 0000000..e6ab577 --- /dev/null +++ b/core/src/main/java/moe/tabidachi/emulator/domain/UpdateAssetsStateUseCase.kt @@ -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 +) { + operator fun invoke(state: AssetsState) { + assetsState.value = state + preferences[AssetsExtracted] = state == AssetsState.Extracted + } +} \ No newline at end of file diff --git a/core/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt b/core/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt new file mode 100644 index 0000000..2cfe530 --- /dev/null +++ b/core/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 905ab86..fd5fd87 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/keystore.properties b/keystore.properties new file mode 100755 index 0000000..2526513 --- /dev/null +++ b/keystore.properties @@ -0,0 +1,4 @@ +signing.storeFile=../platform.jks +signing.storePassword=android +signing.keyAlias=platform +signing.keyPassword=android \ No newline at end of file diff --git a/platform.jks b/platform.jks new file mode 100755 index 0000000..c6a8c2b Binary files /dev/null and b/platform.jks differ diff --git a/settings.gradle.kts b/settings.gradle.kts index 8e3ec6f..d9a6380 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,5 +20,6 @@ dependencyResolutionManagement { } rootProject.name = "Emulator" -include(":app") - \ No newline at end of file +include(":app-default") +include(":core") +include(":ui") diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts new file mode 100644 index 0000000..e19e983 --- /dev/null +++ b/ui/build.gradle.kts @@ -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) +} \ No newline at end of file diff --git a/ui/consumer-rules.pro b/ui/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/ui/proguard-rules.pro b/ui/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/ui/proguard-rules.pro @@ -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 \ No newline at end of file diff --git a/ui/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt b/ui/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..ff80321 --- /dev/null +++ b/ui/src/androidTest/java/moe/tabidachi/emulator/ExampleInstrumentedTest.kt @@ -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) + } +} \ No newline at end of file diff --git a/ui/src/main/AndroidManifest.xml b/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a5918e6 --- /dev/null +++ b/ui/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/AssetsExtractDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/AssetsExtractDialog.kt new file mode 100644 index 0000000..a12480e --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/AssetsExtractDialog.kt @@ -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()) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/BringIntoViewSpec.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/BringIntoViewSpec.kt new file mode 100644 index 0000000..eb50ad8 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/BringIntoViewSpec.kt @@ -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>( + 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 = 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 + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/MediaChangeListener.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/MediaChangeListener.kt new file mode 100644 index 0000000..4c33f9e --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/MediaChangeListener.kt @@ -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) + } + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/NoSdcardDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/NoSdcardDialog.kt new file mode 100644 index 0000000..21d79c8 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/NoSdcardDialog.kt @@ -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)) + } + } + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/PermissionDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/PermissionDialog.kt new file mode 100644 index 0000000..c423905 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/PermissionDialog.kt @@ -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, + 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)) + } + } + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/PreviewSurface.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/PreviewSurface.kt new file mode 100644 index 0000000..99e4764 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/PreviewSurface.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/common/TvPreview.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/common/TvPreview.kt new file mode 100644 index 0000000..dc5404f --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/common/TvPreview.kt @@ -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 \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/components/LinearProgressBar.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/components/LinearProgressBar.kt new file mode 100644 index 0000000..6b18d40 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/components/LinearProgressBar.kt @@ -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 + ) + } +} \ No newline at end of file diff --git a/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt new file mode 100644 index 0000000..ed77fd3 --- /dev/null +++ b/ui/src/main/java/moe/tabidachi/emulator/ui/components/TvDialog.kt @@ -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) diff --git a/app/src/main/java/moe/tabidachi/emulator/ui/theme/Color.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/theme/Color.kt similarity index 100% rename from app/src/main/java/moe/tabidachi/emulator/ui/theme/Color.kt rename to ui/src/main/java/moe/tabidachi/emulator/ui/theme/Color.kt diff --git a/app/src/main/java/moe/tabidachi/emulator/ui/theme/Theme.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/theme/Theme.kt similarity index 100% rename from app/src/main/java/moe/tabidachi/emulator/ui/theme/Theme.kt rename to ui/src/main/java/moe/tabidachi/emulator/ui/theme/Theme.kt diff --git a/app/src/main/java/moe/tabidachi/emulator/ui/theme/Type.kt b/ui/src/main/java/moe/tabidachi/emulator/ui/theme/Type.kt similarity index 100% rename from app/src/main/java/moe/tabidachi/emulator/ui/theme/Type.kt rename to ui/src/main/java/moe/tabidachi/emulator/ui/theme/Type.kt diff --git a/ui/src/main/res/values-zh-rCN/strings.xml b/ui/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..01c892b --- /dev/null +++ b/ui/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,13 @@ + + + 存储权限请求 + 我们需要访问您的存储空间以保存和读取文件。请授予存储权限。 + 允许 + 拒绝 + 加载资源… + 请插入游戏卡 + 未检测到游戏卡,我们将无法为您提供服务。 + 确定 + 取消 + %s GAMES + \ No newline at end of file diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml new file mode 100644 index 0000000..5ec279a --- /dev/null +++ b/ui/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Store permission requests + We need to access your storage space to save and read files. Please grant storage permissions. + Allow + Deny + Loading resources… + Please insert the game card + We will not be able to provide you with the game card not detected. + Confirm + Cancel + %s GAMES + \ No newline at end of file diff --git a/ui/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt b/ui/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt new file mode 100644 index 0000000..1b62b47 --- /dev/null +++ b/ui/src/test/java/moe/tabidachi/emulator/ExampleUnitTest.kt @@ -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) + } +} \ No newline at end of file