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