diff --git a/AndroidManifest.xml b/AndroidManifest.xml index 7d07f16297..8a677ffb7d 100644 --- a/AndroidManifest.xml +++ b/AndroidManifest.xml @@ -21,8 +21,8 @@ + + diff --git a/build.gradle b/build.gradle index 48ebd7748c..503bd5a8f8 100644 --- a/build.gradle +++ b/build.gradle @@ -41,8 +41,6 @@ dependencies { implementation 'androidx.cardview:cardview:1.0.0' implementation 'pub.devrel:easypermissions:3.0.0' implementation 'androidx.recyclerview:recyclerview:1.3.0' - implementation 'com.j256.ormlite:ormlite-android:5.0' - implementation 'com.j256.ormlite:ormlite-core:5.0' implementation 'com.google.android.material:material:1.9.0' implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'commons-io:commons-io:2.12.0' @@ -61,6 +59,14 @@ dependencies { implementation 'androidx.core:core-ktx:1.10.1' implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1' implementation 'uk.co.armedpineapple.innoextract:service:2.1.0' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'com.github.bumptech.glide:glide:4.15.1' + + def room_version = "2.5.2" + + implementation "androidx.room:room-runtime:$room_version" + annotationProcessor "androidx.room:room-compiler:$room_version" + kapt "androidx.room:room-compiler:$room_version" } android { diff --git a/jni/src b/jni/src index 838f8a6fdc..f88361dee0 160000 --- a/jni/src +++ b/jni/src @@ -1 +1 @@ -Subproject commit 838f8a6fdca76134914a24fed98d6415a61a8ab6 +Subproject commit f88361dee0e244c175c08cdd52755a0228af3a38 diff --git a/res/layout/dialog_newsave.xml b/res/layout/dialog_newsave.xml new file mode 100644 index 0000000000..f285d9a423 --- /dev/null +++ b/res/layout/dialog_newsave.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/res/layout/fragment_save_load_list.xml b/res/layout/fragment_save_load_list.xml new file mode 100644 index 0000000000..fb1cbabc92 --- /dev/null +++ b/res/layout/fragment_save_load_list.xml @@ -0,0 +1,13 @@ + + \ No newline at end of file diff --git a/res/layout/new_save_list_item.xml b/res/layout/new_save_list_item.xml new file mode 100644 index 0000000000..792115a9b7 --- /dev/null +++ b/res/layout/new_save_list_item.xml @@ -0,0 +1,29 @@ + + + + + + + \ No newline at end of file diff --git a/res/layout/save_activity.xml b/res/layout/save_activity.xml new file mode 100644 index 0000000000..22bf2d60bc --- /dev/null +++ b/res/layout/save_activity.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/res/layout/save_list_item.xml b/res/layout/save_list_item.xml new file mode 100644 index 0000000000..60b42991f5 --- /dev/null +++ b/res/layout/save_list_item.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/res/values/dimens.xml b/res/values/dimens.xml new file mode 100644 index 0000000000..ca514d86f6 --- /dev/null +++ b/res/values/dimens.xml @@ -0,0 +1,4 @@ + + + 16dp + \ No newline at end of file diff --git a/res/values/strings.xml b/res/values/strings.xml index e13a4ec876..cc1ab30afe 100644 --- a/res/values/strings.xml +++ b/res/values/strings.xml @@ -19,4 +19,5 @@ Cancel %s deleted Undo + New save name \ No newline at end of file diff --git a/res/values/styles.xml b/res/values/styles.xml index b1509ac419..44b08b825e 100644 --- a/res/values/styles.xml +++ b/res/values/styles.xml @@ -1,11 +1,18 @@ - + + + #000000 @@ -29,9 +36,9 @@ 22sp - + + - - \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/CTHApplication.kt b/src/Java/uk/co/armedpineapple/cth/CTHApplication.kt index 280e5e2c74..10959cabc6 100644 --- a/src/Java/uk/co/armedpineapple/cth/CTHApplication.kt +++ b/src/Java/uk/co/armedpineapple/cth/CTHApplication.kt @@ -1,12 +1,24 @@ package uk.co.armedpineapple.cth import androidx.preference.PreferenceManager +import androidx.room.Room import org.jetbrains.anko.defaultSharedPreferences +import uk.co.armedpineapple.cth.files.FilesService +import uk.co.armedpineapple.cth.files.persistence.GameDatabase import uk.co.armedpineapple.cth.localisation.LanguageService class CTHApplication : android.app.Application() { lateinit var configuration: GameConfiguration + val database: GameDatabase by lazy { + Room.databaseBuilder( + this, GameDatabase::class.java, "database" + ).build() + } + + val filesService: FilesService by lazy { + FilesService(this) + } override fun onCreate() { super.onCreate() @@ -18,7 +30,9 @@ class CTHApplication : android.app.Application() { val preferences = defaultSharedPreferences val service = LanguageService(this) - preferences.edit().putString(this.getString(R.string.prefs_language), service.getCthLanguageFromAppConfig()).apply() + preferences.edit().putString( + this.getString(R.string.prefs_language), service.getCthLanguageFromAppConfig() + ).apply() if (!preferences.getBoolean( PreferenceManager.KEY_HAS_SET_DEFAULT_VALUES, false diff --git a/src/Java/uk/co/armedpineapple/cth/GameActivity.kt b/src/Java/uk/co/armedpineapple/cth/GameActivity.kt index 417149fabb..a5019ea481 100644 --- a/src/Java/uk/co/armedpineapple/cth/GameActivity.kt +++ b/src/Java/uk/co/armedpineapple/cth/GameActivity.kt @@ -3,40 +3,49 @@ package uk.co.armedpineapple.cth import android.content.Intent import android.os.Bundle import android.util.Log -import android.view.View -import android.widget.RelativeLayout import android.widget.Toast +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.Keep -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.viewModelScope -import com.google.common.io.ByteStreams -import com.google.common.io.Closeables import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import org.jetbrains.anko.AnkoLogger import org.libsdl.app.SDLActivity import uk.co.armedpineapple.cth.files.FilesService +import uk.co.armedpineapple.cth.files.SaveGameContract +import uk.co.armedpineapple.cth.files.persistence.SaveData import uk.co.armedpineapple.cth.settings.SettingsActivity import uk.co.armedpineapple.cth.setup.SetupActivity import java.io.File -import java.io.FileOutputStream -import java.io.InputStream -import java.util.zip.ZipFile +import java.nio.charset.Charset class GameActivity : SDLActivity(), AnkoLogger { + @Keep + private external fun startLogger() + + @Keep + private external fun nativeSave(saveName: String) @Keep - private external fun startLogger(); + private external fun nativeLoad(saveName: String) + private val configuration: GameConfiguration get() = (application as CTHApplication).configuration + private val filesService: FilesService get() = (application as CTHApplication).filesService + + private val saveDao get() = (application as CTHApplication).database.saveDao() + + private val loadGameLauncher: ActivityResultLauncher = + registerForActivityResult(SaveGameContract()) { o -> doLoad(o) } + private val saveGameLauncher: ActivityResultLauncher = + registerForActivityResult(SaveGameContract()) { o -> doSave(o) } + override fun onCreate(savedInstanceState: Bundle?) { + singleton = this val filesService = FilesService(this) // Check whether the setup installation has run and the original TH files are available. @@ -53,8 +62,13 @@ class GameActivity : SDLActivity(), AnkoLogger { // Install the latest CTH game files in the background. var installJob: Job? = null - if (!filesService.hasGameFiles(configuration)) { + val alwaysUpgrade = true + if (alwaysUpgrade || !filesService.hasGameFiles(configuration)) { Toast.makeText(this, "Upgrading", Toast.LENGTH_SHORT).show() + + val target = configuration.cthFiles + if (target.exists()) target.deleteRecursively() + installJob = CoroutineScope(Dispatchers.IO).launch { filesService.installGameFiles(configuration) } @@ -66,7 +80,6 @@ class GameActivity : SDLActivity(), AnkoLogger { super.onCreate(savedInstanceState) startLogger() - singleton = this // Make sure the game file installation installation has completed before moving on. if (installJob != null) { @@ -76,6 +89,49 @@ class GameActivity : SDLActivity(), AnkoLogger { } } + private fun launchLoadGamePicker() { + loadGameLauncher.launch(true) + } + + private fun launchSaveGamePicker() { + saveGameLauncher.launch(false) + } + + private fun doLoad(saveName: String?) { + saveName?.let { save -> + val savePath = filesService.getSaveFile(save, configuration) + + if (savePath.exists()) { + nativeLoad(savePath.absolutePath) + } + } + + } + + private fun doSave(saveName: String?) { + if (saveName != null) { + val savePath = File(configuration.saveFiles, saveName) + nativeSave(savePath.absolutePath) + } + } + + private fun updateSaveGameDatabase( + filePath: String, rep: Int, money: Long, level: String, screenshot: String + ) { + val fileName = File(filePath).name + CoroutineScope(Dispatchers.IO).launch { + saveDao.upsert( + SaveData( + saveName = fileName, + screenshotPath = screenshot, + rep = rep, + money = money, + levelName = level + ) + ) + } + } + @Override override fun getMainSharedObject(): String { return getContext().applicationInfo.nativeLibraryDir + "/" + "libappmain.so" @@ -101,7 +157,7 @@ class GameActivity : SDLActivity(), AnkoLogger { } companion object { - var singleton: GameActivity? = null + lateinit var singleton: GameActivity @Keep @JvmStatic @@ -109,8 +165,33 @@ class GameActivity : SDLActivity(), AnkoLogger { Log.i("GameActivity", "Showing settings") val intent = Intent(singleton, SettingsActivity::class.java) - singleton?.startActivity(intent); + singleton.startActivity(intent); + } + + @Keep + @JvmStatic + fun showLoad() { + singleton.launchLoadGamePicker() + } + + @Keep + @JvmStatic + fun showSave() { + singleton.launchSaveGamePicker() } - } + @Keep + @JvmStatic + fun onSaveGameChanged( + fileName: ByteArray, rep: Int, money: Long, level: ByteArray, screenshot: ByteArray + ) { + singleton.updateSaveGameDatabase( + filePath = String(fileName, Charset.forName("UTF-8")), + rep = rep, + money = money, + level = String(level, Charset.forName("UTF-8")), + screenshot = String(screenshot, Charset.forName("UTF-8")) + ) + } + } } \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/GameConfiguration.kt b/src/Java/uk/co/armedpineapple/cth/GameConfiguration.kt index 6027bcb7a3..0cdd73c940 100644 --- a/src/Java/uk/co/armedpineapple/cth/GameConfiguration.kt +++ b/src/Java/uk/co/armedpineapple/cth/GameConfiguration.kt @@ -22,18 +22,22 @@ class GameConfiguration(private val ctx: Context, private val preferences: Share val cthLaunchScript = File(cthFiles, "CorsixTH.lua") val gameConfigFile = File(cthFiles, "config.txt") val thFiles: File = File(ctx.noBackupFilesDir, "themehospital") + val saveFiles: File = File(ctx.filesDir, "saves") + val autosaveFiles : File = File(saveFiles, "Autosaves") + val screenshots : File = File(ctx.filesDir, "screenshots") + private val unicodeFont = "/system/fonts/NotoSerif-Regular.ttf" - var resolution: Pair = decodeResolution(getStringPref(R.string.prefs_display_resolution).toUInt()) + var resolution: Pair = + decodeResolution(getStringPref(R.string.prefs_display_resolution).toUInt()) val fullscreen = true val language: String by createReadOnlyOption(R.string.prefs_language) - init { - refresh() - } - fun persist() { + // Create save game directory if it doesn't already exist, otherwise CTH will use its own. + + saveFiles.mkdirs() val tokenMap = hashMapOf() tokenMap.putAll(getStringPrefs(arrayOf(R.string.prefs_language))) @@ -71,6 +75,8 @@ class GameConfiguration(private val ctx: Context, private val preferences: Share (!getBoolPref(R.string.prefs_input_edge_scrolling)).toString() tokenMap["th_path"] = thFiles.absolutePath + tokenMap["save_path"] = saveFiles.absolutePath + tokenMap["screenshots_path"] = screenshots.absolutePath tokenMap["unicode_font_path"] = unicodeFont tokenMap["res_w"] = resolution.first.toString() tokenMap["res_h"] = resolution.second.toString() @@ -95,10 +101,6 @@ class GameConfiguration(private val ctx: Context, private val preferences: Share } } - fun refresh() { - - } - private fun decodeResolution(encodedValue: UInt): Pair { val width = (encodedValue shr 16) val height = (encodedValue and 0xFFFFu) @@ -108,12 +110,15 @@ class GameConfiguration(private val ctx: Context, private val preferences: Share private fun getBoolPref(prefId: Int): Boolean { return preferences.getBoolean(ctx.getString(prefId), false) } - private fun getIntPref(prefId : Int) : Int { + + private fun getIntPref(prefId: Int): Int { return preferences.getInt(ctx.getString(prefId), 0) } - private fun getStringPref(prefId : Int) : String { + + private fun getStringPref(prefId: Int): String { return preferences.getString(ctx.getString(prefId), "") as String } + private fun getStringPrefs(prefs: Array) = sequence { for (pref in prefs) { val prefName = ctx.getString(pref) diff --git a/src/Java/uk/co/armedpineapple/cth/files/FilesService.kt b/src/Java/uk/co/armedpineapple/cth/files/FilesService.kt index af5f9e660a..7f5e8a623f 100644 --- a/src/Java/uk/co/armedpineapple/cth/files/FilesService.kt +++ b/src/Java/uk/co/armedpineapple/cth/files/FilesService.kt @@ -29,6 +29,26 @@ class FilesService(val ctx: Context) : AnkoLogger { data class DeterminateFileOperationProgress(val progress: Long, val max: Long) data class EstimatedFileOperationProgress(val progress: Float) + /** + * Gets all the save game files. + * + * @param config The configuration + * @return An array of save game files + */ + fun getSaveGameFiles(config: GameConfiguration): Array { + return getSaveDirectoryContents(config.saveFiles) + } + + /** + * Gets all the autosave game files. + * + * @param config The configuration + * @return An array of save game files + */ + fun getAutoSaveGameFiles(config: GameConfiguration): Array { + return getSaveDirectoryContents(config.autosaveFiles) + } + /** * Checks whether the game files exist in the location given in the config. * @@ -90,7 +110,6 @@ class FilesService(val ctx: Context) : AnkoLogger { try { val target = config.cthFiles - if (target.exists()) target.deleteRecursively() extractZipFile(assetOut, target, progress) } finally { assetOut.delete() @@ -123,6 +142,21 @@ class FilesService(val ctx: Context) : AnkoLogger { copyDirectoryTree(source, config.thFiles, progress) } + /** + * Gets a File corresponding to the given save name. + * + * @param saveName The save name. + * @param config The configuration that determines the save file locations. + * @return A file for the given save name. + */ + fun getSaveFile(saveName : String, config: GameConfiguration) : File { + return if (saveName.startsWith("Autosave")) { + File(config.autosaveFiles, saveName) + } else { + File(config.saveFiles, saveName) + } + } + private suspend fun copyDirectoryTree( root: DocumentFileCompat, destinationDirectory: File, @@ -245,7 +279,16 @@ class FilesService(val ctx: Context) : AnkoLogger { } } + private fun getSaveDirectoryContents(root: File): Array { + if (root.exists()) { + return root.listFiles { f -> f.isFile && f.extension.lowercase() == SAVE_GAME_EXTENSION } ?: arrayOf() + } + return arrayOf() + } + companion object { private const val ENGINE_ZIP_FILE = "game.zip" + const val SAVE_GAME_EXTENSION = "sav" + const val SAVE_GAME_FILE_SUFFIX = ".$SAVE_GAME_EXTENSION" } } diff --git a/src/Java/uk/co/armedpineapple/cth/files/SaveActivity.kt b/src/Java/uk/co/armedpineapple/cth/files/SaveActivity.kt new file mode 100644 index 0000000000..5ca60143a0 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SaveActivity.kt @@ -0,0 +1,41 @@ +package uk.co.armedpineapple.cth.files + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.setFragmentResultListener +import uk.co.armedpineapple.cth.R + +/** + * An activity for loading or saving games. + */ +class SaveActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContentView(R.layout.save_activity) + val isLoad = intent.getBooleanExtra(EXTRA_IS_LOAD, false); + if (savedInstanceState == null) { + val newFragment = SaveLoadFragment.newInstance(isLoad) + + supportFragmentManager.beginTransaction().replace(R.id.save, newFragment).commit() + + newFragment.setFragmentResultListener(SaveLoadFragment.REQUEST_SELECT) { _, bundle -> + val fileName = bundle.getString(SaveLoadFragment.BUNDLE_FILENAME) + setResult(Activity.RESULT_OK, Intent().putExtra(EXTRA_SAVE_GAME_NAME, fileName)) + finish() + } + } + + title = when (isLoad) { + false -> getString(R.string.save_game) + true -> getString(R.string.load_game) + } + } + + companion object { + const val EXTRA_SAVE_GAME_NAME: String = "extra.savegamename" + const val EXTRA_IS_LOAD: String = "extra.isload" + } +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/SaveGameContract.kt b/src/Java/uk/co/armedpineapple/cth/files/SaveGameContract.kt new file mode 100644 index 0000000000..53122e42f0 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SaveGameContract.kt @@ -0,0 +1,23 @@ +package uk.co.armedpineapple.cth.files + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.activity.result.contract.ActivityResultContract + +/** + * A contract for requesting either loading or saving a save game. + */ +class SaveGameContract : ActivityResultContract() { + override fun createIntent(context: Context, input: Boolean): Intent { + return Intent(context, SaveActivity::class.java).putExtra(SaveActivity.EXTRA_IS_LOAD, input) + } + + override fun parseResult(resultCode: Int, intent: Intent?): String? { + if (resultCode != Activity.RESULT_OK) { + return null + } + + return intent?.getStringExtra(SaveActivity.EXTRA_SAVE_GAME_NAME) + } +} diff --git a/src/Java/uk/co/armedpineapple/cth/files/SaveGameViewModel.kt b/src/Java/uk/co/armedpineapple/cth/files/SaveGameViewModel.kt new file mode 100644 index 0000000000..2ea79c06a7 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SaveGameViewModel.kt @@ -0,0 +1,87 @@ +package uk.co.armedpineapple.cth.files + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import uk.co.armedpineapple.cth.CTHApplication +import uk.co.armedpineapple.cth.files.persistence.SaveData +import java.io.File +import java.time.Instant +import java.time.ZoneId + +/** + * View model for save games + * + * @param application The application + */ +class SaveGameViewModel(application: Application) : AndroidViewModel(application) { + + private val saveDao = getApplication().database.saveDao() + private val filesService = getApplication().filesService + private val config = getApplication().configuration + private val mutableSaves = MutableLiveData>() + private val mutableAutosaves = MutableLiveData>() + + val saves: LiveData> = mutableSaves + val autosaves: LiveData> = mutableAutosaves + + init { + onSavesUpdated() + } + + /** + * To be called when saved games have been updated. + */ + fun onSavesUpdated() { + CoroutineScope(Dispatchers.IO).launch { + mutableSaves.postValue(getSaves()) + mutableAutosaves.postValue(getAutosaves()) + } + } + + /** + * Deletes a save game and all its related stored data. + * + * @param saveData The SaveData + */ + fun deleteSave(saveData: SaveData) { + saveData.screenshotPath?.let { + val screenshotFile = File(it) + if (screenshotFile.exists()) screenshotFile.delete() + } + + val gameFile = filesService.getSaveFile(saveData.saveName, config) + if (gameFile.exists()) gameFile.delete() + + CoroutineScope(Dispatchers.IO).launch { + saveDao.delete(saveData) + onSavesUpdated() + } + } + + private fun getSaves(): List { + val saveFiles = filesService.getSaveGameFiles(config) + return associateSaves(saveFiles) + } + + private fun getAutosaves(): List { + val saveFiles = filesService.getAutoSaveGameFiles(config) + return associateSaves(saveFiles) + } + + private fun associateSaves(saveFiles: Array): List { + val saveRecords = saveDao.getAll().associateBy { it.saveName } + + return saveFiles.map { file -> + saveRecords.getOrElse(file.name) { SaveData(file.name) }.also { + it.saveDate = + Instant.ofEpochMilli(file.lastModified()).atZone(ZoneId.systemDefault()) + .toLocalDateTime() + } + } + } +} diff --git a/src/Java/uk/co/armedpineapple/cth/files/SaveLoadFragment.kt b/src/Java/uk/co/armedpineapple/cth/files/SaveLoadFragment.kt new file mode 100644 index 0000000000..f7c9d422eb --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SaveLoadFragment.kt @@ -0,0 +1,146 @@ +package uk.co.armedpineapple.cth.files + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.lifecycle.ViewModelProvider +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import uk.co.armedpineapple.cth.R +import uk.co.armedpineapple.cth.files.persistence.SaveData +import kotlin.properties.Delegates + + +/** + * A fragment for saving or loading a saved game from a list of saved games. + */ +class SaveLoadFragment : Fragment() { + + private lateinit var viewModel: SaveGameViewModel + private var isLoad by Delegates.notNull() + private val columnCount = 1 + private var adapter: SaveLoadRecyclerViewAdapter? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + isLoad = arguments?.getBoolean(BUNDLE_ISLOAD, false) ?: false; + + viewModel = ViewModelProvider(this)[SaveGameViewModel::class.java] + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? + ): View? { + val view = inflater.inflate(R.layout.fragment_save_load_list, container, false) + + val newSaveDialogView = inflater.inflate(R.layout.dialog_newsave, null) + + val newSaveDialog = AlertDialog.Builder(requireContext()).setView(newSaveDialogView) + .setTitle(R.string.new_save).setPositiveButton(R.string.save) { _, _ -> + val name = + newSaveDialogView.findViewById(R.id.newSaveName).text.toString() + setFragmentResult( + REQUEST_SELECT, + bundleOf(BUNDLE_FILENAME to name + FilesService.SAVE_GAME_FILE_SUFFIX) + ) + }.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.cancel() }.create() + + // Set the adapter + if (view is RecyclerView) { + val newAdapter = SaveLoadRecyclerViewAdapter( + context = requireContext(), + vm = viewModel, + showNew = !isLoad, + showAutosaves = isLoad, + newItemCallback = { newSaveDialog.show() }, + saveItemCallback = { + if (isLoad) { + setFragmentResult( + REQUEST_SELECT, bundleOf(BUNDLE_FILENAME to it.saveName) + ) + } else { + showConfirmDialog(it) + + } + }, + userItemRemovedCallback = ::onSaveDeleteRequested + ) + this.adapter = newAdapter; + val itemTouchHelper = + ItemTouchHelper(SwipeToDeleteCallback(requireContext(), newAdapter)) + + with(view) { + layoutManager = when { + columnCount <= 1 -> LinearLayoutManager(context) + else -> GridLayoutManager(context, columnCount) + } + + adapter = this@SaveLoadFragment.adapter + itemTouchHelper.attachToRecyclerView(view) + } + } + + return view + } + + private fun onSaveDeleteRequested(position: Int, saveData: SaveData) { + Snackbar.make( + requireActivity().window.decorView, String.format( + getString(R.string.s_deleted), + saveData.saveName.removeSuffix(FilesService.SAVE_GAME_FILE_SUFFIX) + ), Snackbar.LENGTH_LONG + ).addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar?, event: Int) { + super.onDismissed(transientBottomBar, event) + if (event != DISMISS_EVENT_ACTION) { + viewModel.deleteSave(saveData) + } + } + }).setAction(getString(R.string.undo)) { adapter?.reinsert(position, saveData) }.show(); + } + + private fun showConfirmDialog(saveData: SaveData) { + val name = saveData.saveName.removeSuffix(FilesService.SAVE_GAME_FILE_SUFFIX) + AlertDialog.Builder(requireContext()).setTitle(getString(R.string.overwrite_save)) + .setMessage( + String.format( + getString(R.string.this_will_overwrite_saved_game_s_are_you_sure), name + ) + ).setPositiveButton(getString(R.string.save)) { _, _ -> + setFragmentResult( + REQUEST_SELECT, bundleOf(BUNDLE_FILENAME to saveData.saveName) + ) + }.setNegativeButton(getString(R.string.cancel)) { dialog, _ -> dialog.cancel() } + .create().show() + } + + companion object { + const val REQUEST_SELECT = "request_select" + const val BUNDLE_FILENAME = "bundle_filename" + const val BUNDLE_ISLOAD = "bundle_isload" + + /** + * Creates a new instance of SaveLoadFragment + * + * @param isLoad Whether the fragment is used for loading or saving a game. + * @return A new SaveLoadFragment + */ + fun newInstance(isLoad: Boolean): SaveLoadFragment { + val fragment = SaveLoadFragment() + val args = Bundle() + args.putBoolean(BUNDLE_ISLOAD, isLoad) + fragment.arguments = args + return fragment + } + } +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/SaveLoadRecyclerViewAdapter.kt b/src/Java/uk/co/armedpineapple/cth/files/SaveLoadRecyclerViewAdapter.kt new file mode 100644 index 0000000000..74efcee599 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SaveLoadRecyclerViewAdapter.kt @@ -0,0 +1,166 @@ +package uk.co.armedpineapple.cth.files + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.ViewHolder +import com.bumptech.glide.Glide +import uk.co.armedpineapple.cth.R +import uk.co.armedpineapple.cth.databinding.SaveListItemBinding +import uk.co.armedpineapple.cth.files.persistence.SaveData +import java.io.File +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle + +/** + * A RecyclerView adapter for presenting a list of save games. + * + * @property context A context + * @property vm A SaveGameViewMdoel + * @property showNew Whether the option for a new save should be presented + * @property showAutosaves Whether autosaves should be presented + * @property newItemCallback A callback for when the new item option is selected + * @property saveItemCallback A callback for when an existing item is selected + * @property userItemRemovedCallback A callback for when the user requests an item for deletion. + * The adapter does not persist any deletion itself, other than to its own internal collection. + */ +class SaveLoadRecyclerViewAdapter( + private val context: Context, + private val vm: SaveGameViewModel, + private val showNew: Boolean = true, + private val showAutosaves: Boolean = true, + private val newItemCallback: () -> Unit, + private val saveItemCallback: (saveData: SaveData) -> Unit, + private val userItemRemovedCallback: (position: Int, saveData: SaveData) -> Unit +) : RecyclerView.Adapter() { + + private var values: MutableList = getOrderedSaves().toMutableList() + + private val dataObserver: Observer> = + Observer { values = getOrderedSaves().toMutableList() } + + /** + * Removes an item at a given position. + * + * @param position The position of the item to remove. + */ + fun removeAt(position: Int) { + val item = values[position]; + values.removeAt(position) + notifyItemRemoved(position) + userItemRemovedCallback(position, item) + } + + /** + * Reinserts a removed item at its original position. + * + * @param position The position the item was originally in. + * @param saveData The item + */ + fun reinsert(position: Int, saveData: SaveData) { + values.add(position, saveData) + notifyItemInserted(position) + } + + override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { + super.onAttachedToRecyclerView(recyclerView) + vm.saves.observeForever(dataObserver) + vm.autosaves.observeForever(dataObserver) + } + + override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { + super.onDetachedFromRecyclerView(recyclerView) + vm.saves.removeObserver(dataObserver) + vm.autosaves.removeObserver(dataObserver) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return when (viewType) { + ViewTypes.SAVE.ordinal -> SaveViewHolder( + SaveListItemBinding.inflate( + layoutInflater, parent, false + ) + ) + + else -> { + NewSaveViewHolder( + layoutInflater.inflate( + R.layout.new_save_list_item, parent, false + ) + ) + } + } + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + if (holder is SaveViewHolder) { + val item = values[if (showNew) position - 1 else position] + holder.levelView.text = item.levelName + holder.dateView.text = item.saveDate?.format( + DateTimeFormatter.ofLocalizedDateTime( + FormatStyle.MEDIUM + ) + ) + holder.levelView.text = item.levelName + holder.moneyView.text = item.money.toString() + holder.repView.text = item.rep.toString() + holder.nameView.text = item.saveName.removeSuffix(FilesService.SAVE_GAME_FILE_SUFFIX) + + item.screenshotPath?.let { + Glide.with(context).load(File(it)).into(holder.screenshotView) + } + + holder.itemView.setOnClickListener { saveItemCallback(item) } + } else if (holder is NewSaveViewHolder) { + holder.itemView.setOnClickListener { newItemCallback() } + } + } + + override fun getItemCount(): Int = values.size + if (showNew) 1 else 0 + + private fun getOrderedSaves(): List { + val saves = vm.saves.value + + val combined = if (showAutosaves) { + val autosaves = vm.autosaves.value + saves?.let { s -> + autosaves?.let { a -> + s.plus(a) + } + } + } else { + saves + } + + return combined?.let { + it.sortedByDescending { sd -> sd.saveDate } + } ?: listOf() + } + + override fun getItemViewType(position: Int): Int { + return if (!showNew || position > 0) ViewTypes.SAVE.ordinal + else ViewTypes.NEWSAVE.ordinal + } + + private enum class ViewTypes { + SAVE, + NEWSAVE + } + + private inner class NewSaveViewHolder(v: View) : ViewHolder(v) + + private inner class SaveViewHolder(binding: SaveListItemBinding) : ViewHolder(binding.root) { + val levelView: TextView = binding.level + val moneyView: TextView = binding.money + val repView: TextView = binding.rep + val screenshotView: ImageView = binding.saveImage + val nameView: TextView = binding.saveName + val dateView: TextView = binding.saveDate + } +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/SwipeToDeleteCallback.kt b/src/Java/uk/co/armedpineapple/cth/files/SwipeToDeleteCallback.kt new file mode 100644 index 0000000000..96ead3d985 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/SwipeToDeleteCallback.kt @@ -0,0 +1,79 @@ +package uk.co.armedpineapple.cth.files + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.graphics.drawable.Drawable +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import uk.co.armedpineapple.cth.R + + +/** + * Provides swipe-to-delete functionality to the SaveLoadRecyclerViewAdapter + * + * @property adapter The SaveLoadRecyclerViewAdapter + * + * @param context A context aligning with the provided adapter. + */ +class SwipeToDeleteCallback( + context: Context, private val adapter: SaveLoadRecyclerViewAdapter +) : ItemTouchHelper.SimpleCallback(0, ItemTouchHelper.LEFT) { + + private val icon = + ContextCompat.getDrawable(context, R.drawable.baseline_delete_forever_24)?.let { drawable -> + DrawableCompat.wrap(drawable).also { wrappedDrawable -> + DrawableCompat.setTint(wrappedDrawable, Color.WHITE) + } + } + + private val background = ColorDrawable(Color.RED) + + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + // Do nothing. Not supported. + return false; + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val position = viewHolder.bindingAdapterPosition + adapter.removeAt(position) + } + + override fun onChildDraw( + c: Canvas, + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + dX: Float, + dY: Float, + actionState: Int, + isCurrentlyActive: Boolean + ) { + val itemView = viewHolder.itemView + icon?.let { icon -> + val iconMargin = (itemView.height - icon.intrinsicHeight) / 2 + val iconTop = itemView.top + (itemView.height - icon.intrinsicHeight) / 2 + val iconBottom = iconTop + icon.intrinsicHeight + + if (dX < 0) { // Swiping to the left + val iconLeft = itemView.right - iconMargin - icon.intrinsicWidth + val iconRight = itemView.right - iconMargin + icon.setBounds(iconLeft, iconTop, iconRight, iconBottom) + background.setBounds( + itemView.right + dX.toInt(), itemView.top, itemView.right, itemView.bottom + ) + background.draw(c) + icon.draw(c) + } else { + background.setBounds(0, 0, 0, 0) + } + } + super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive) + } +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/persistence/GameDatabase.kt b/src/Java/uk/co/armedpineapple/cth/files/persistence/GameDatabase.kt new file mode 100644 index 0000000000..06f9fbf6c6 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/persistence/GameDatabase.kt @@ -0,0 +1,17 @@ +package uk.co.armedpineapple.cth.files.persistence + +import androidx.room.Database +import androidx.room.RoomDatabase + +/** + * Game database. This stores non-essential game metadata. + */ +@Database(entities = [SaveData::class], version = 1) +abstract class GameDatabase : RoomDatabase() { + /** + * Gets a DAO for save game information + * + * @return A SaveDao + */ + abstract fun saveDao() : SaveDao +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveDao.kt b/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveDao.kt new file mode 100644 index 0000000000..ce30e7aebb --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveDao.kt @@ -0,0 +1,53 @@ +package uk.co.armedpineapple.cth.files.persistence + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Query +import androidx.room.Upsert + +/** + * DAO for save game metadata + */ +@Dao +interface SaveDao { + /** + * Upsert a SaveData + * + * @param save The SaveData + */ + @Upsert + fun upsert(save: SaveData) + + /** + * Deletes a SaveData + * + * @param save The SaveData + */ + @Delete + fun delete(save: SaveData) + + /** + * Deletes a SaveData identified by SaveName + * + * @param save The SaveName + */ + @Delete(entity = SaveData::class) + fun delete(save: SaveName) + + /** + * Gets all SaveData + * + * @return All SaveData + */ + @Query("SELECT * from saves") + fun getAll(): List + + /** + * Gets a single SaveData identified by its name + * + * @param name The save name + * @return The SaveData corresponding to name + */ + @Query("SELECT * from saves WHERE save_name = :name") + fun get(name: String) : SaveData +} \ No newline at end of file diff --git a/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveData.kt b/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveData.kt new file mode 100644 index 0000000000..2819a5db43 --- /dev/null +++ b/src/Java/uk/co/armedpineapple/cth/files/persistence/SaveData.kt @@ -0,0 +1,44 @@ +package uk.co.armedpineapple.cth.files.persistence + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +/** + * Data relating to a saved game + * + * @property saveName The name of the saved game. This should match the filename of the saved game file. + * @property screenshotPath A path to the screenshot of the saved game. + * @property rep The amount of reputation. + * @property money The bank balance. + * @property levelName The name of the level. + */ +@Entity(tableName = "saves") +data class SaveData( + @PrimaryKey @ColumnInfo(name = "save_name") val saveName: String, + + @ColumnInfo(name = "screenshot") var screenshotPath: String? = null, + + @ColumnInfo(name = "rep") var rep: Int? = null, + + @ColumnInfo(name = "money") var money: Long? = null, + + @ColumnInfo(name = "level_name") var levelName: String? = null +) +{ + /** + * Gets the date of the save. + */ + @Ignore var saveDate: LocalDateTime? = null +} + +/** + * Gets an identifier for SaveData by name + * + * @property saveName The save game name. + */ +data class SaveName( + @ColumnInfo(name = "save_name") val saveName: String +) diff --git a/src/Java/uk/co/armedpineapple/cth/persistence/PersistenceHelper.kt b/src/Java/uk/co/armedpineapple/cth/persistence/PersistenceHelper.kt deleted file mode 100644 index 48911c8bbd..0000000000 --- a/src/Java/uk/co/armedpineapple/cth/persistence/PersistenceHelper.kt +++ /dev/null @@ -1,44 +0,0 @@ -package uk.co.armedpineapple.cth.persistence - - -import android.content.Context -import android.database.sqlite.SQLiteDatabase - -import com.j256.ormlite.android.apptools.OrmLiteSqliteOpenHelper -import com.j256.ormlite.dao.Dao -import com.j256.ormlite.support.ConnectionSource -import com.j256.ormlite.table.TableUtils - -import java.sql.SQLException - -import uk.co.armedpineapple.cth.R -import uk.co.armedpineapple.cth.Reporting - -class PersistenceHelper(context: Context) : OrmLiteSqliteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { - - - override fun onCreate(database: SQLiteDatabase, connectionSource: ConnectionSource) { - try { - TableUtils.createTable(connectionSource, SaveData::class.java) - } catch (e: SQLException) { - Reporting.report(e) - } - - } - - override fun onUpgrade(database: SQLiteDatabase, connectionSource: ConnectionSource, oldVersion: Int, newVersion: Int) { - try { - TableUtils.dropTable(connectionSource, SaveData::class.java, false) - } catch (e: SQLException) { - Reporting.report(e) - } - - onCreate(database, connectionSource) - } - - companion object { - - private const val DATABASE_NAME = "CorsixTH" - private const val DATABASE_VERSION = 1 - } -} diff --git a/src/Java/uk/co/armedpineapple/cth/persistence/SaveData.kt b/src/Java/uk/co/armedpineapple/cth/persistence/SaveData.kt deleted file mode 100644 index d3964b0374..0000000000 --- a/src/Java/uk/co/armedpineapple/cth/persistence/SaveData.kt +++ /dev/null @@ -1,29 +0,0 @@ -package uk.co.armedpineapple.cth.persistence - -import com.j256.ormlite.field.DataType -import com.j256.ormlite.field.DatabaseField -import com.j256.ormlite.table.DatabaseTable - -import java.util.Date - -@DatabaseTable(tableName = "saves") -class SaveData { - - @DatabaseField(id = true) - var saveName: String? = null - - @DatabaseField - var screenshotPath: String? = null - - @DatabaseField - var rep: Int = 0 - - @DatabaseField - var money: Long = 0 - - @DatabaseField - var levelName: String? = null - - @DatabaseField(version = true, dataType = DataType.DATE_LONG) - var lastModified: Date? = null -}