diff --git a/README.md b/README.md
index d396b7b5..6cb99ece 100644
--- a/README.md
+++ b/README.md
@@ -34,14 +34,14 @@ In no particular order:
- [ ] Own custom keyboard overlay, maybe even per-content configs
- Not an overlay, and not per-content, but custom keyboard is there
- [ ] Error/panic handling
-- [ ] Loading "animation" (spinner)
+- [X] Loading "animation" (spinner)
- [ ] Alternative audio backend (OpenSL ES) for Android < 8
- [ ] Proper storage backend?
-- [ ] Resolve design glitches/styling/theming (immersive mode, window insets for holes/notches/corners)
+- [X] Resolve design glitches/styling/theming (immersive mode, window insets for holes/notches/corners)
- [ ] Publish to various app stores, maybe automatically?
-- [ ] Bundle demo animations/games
+- [X] Bundle demo animations/games
- [ ] Add ability to load content from well known online collections? (well maybe not z0r... unless?)
-- [ ] History, favorites, other flair...?
+- [X] History, favorites, other flair...?
### DONE:
@@ -73,7 +73,7 @@ In no particular order:
- [X] Unglitchify audio volume (buttons unresponsive?)
- (pending: https://github.com/rust-windowing/winit/pull/1919)
- actually solved by switching to GameActivity instead
-- [ ] Register Ruffle to open .swf files
+- [X] Register Ruffle to open .swf files
- How well this works depends on the application opening the file, but it "should work most of the time"
- [X] Figure out why videos are not playing (could be a seeking issue)
- The video decoder features weren't enabled on `ruffle_core`...
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 072a2497..bf9d62bd 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -10,6 +10,7 @@ plugins {
alias(libs.plugins.jetbrainsKotlinAndroid)
alias(libs.plugins.cargoNdkAndroid)
alias(libs.plugins.composeCompiler)
+ kotlin("plugin.serialization") version "1.9.10"
}
android {
@@ -105,12 +106,14 @@ dependencies {
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
+ implementation("androidx.compose.material:material-icons-extended:1.6.0")
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.navigation.runtime.ktx)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.games.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.androidx.appcompat)
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
androidTestImplementation(libs.androidx.uiautomator)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.androidx.test.rules)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 93a23172..3bde1887 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -65,5 +65,14 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/assets/demos/alien_hominid.swf b/app/src/main/assets/demos/alien_hominid.swf
new file mode 100644
index 00000000..2788d02b
Binary files /dev/null and b/app/src/main/assets/demos/alien_hominid.swf differ
diff --git a/app/src/main/assets/demos/bitey1.swf b/app/src/main/assets/demos/bitey1.swf
new file mode 100644
index 00000000..fe2183fd
Binary files /dev/null and b/app/src/main/assets/demos/bitey1.swf differ
diff --git a/app/src/main/assets/demos/flyguy.swf b/app/src/main/assets/demos/flyguy.swf
new file mode 100644
index 00000000..fd388ead
Binary files /dev/null and b/app/src/main/assets/demos/flyguy.swf differ
diff --git a/app/src/main/assets/demos/marvin_spectrum.swf b/app/src/main/assets/demos/marvin_spectrum.swf
new file mode 100644
index 00000000..97512659
Binary files /dev/null and b/app/src/main/assets/demos/marvin_spectrum.swf differ
diff --git a/app/src/main/assets/demos/saturday_morning_watchmen.swf b/app/src/main/assets/demos/saturday_morning_watchmen.swf
new file mode 100644
index 00000000..25bbeab6
Binary files /dev/null and b/app/src/main/assets/demos/saturday_morning_watchmen.swf differ
diff --git a/app/src/main/assets/demos/synj1.swf b/app/src/main/assets/demos/synj1.swf
new file mode 100644
index 00000000..4fec2eb6
Binary files /dev/null and b/app/src/main/assets/demos/synj1.swf differ
diff --git a/app/src/main/assets/demos/synj2.swf b/app/src/main/assets/demos/synj2.swf
new file mode 100644
index 00000000..bea2d5d8
Binary files /dev/null and b/app/src/main/assets/demos/synj2.swf differ
diff --git a/app/src/main/assets/demos/wasted_sky.swf b/app/src/main/assets/demos/wasted_sky.swf
new file mode 100644
index 00000000..648b92b5
Binary files /dev/null and b/app/src/main/assets/demos/wasted_sky.swf differ
diff --git a/app/src/main/java/rs/ruffle/DemosManager.kt b/app/src/main/java/rs/ruffle/DemosManager.kt
new file mode 100644
index 00000000..e03ee1c8
--- /dev/null
+++ b/app/src/main/java/rs/ruffle/DemosManager.kt
@@ -0,0 +1,224 @@
+package rs.ruffle
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import androidx.core.content.FileProvider
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * Manages bundled demo SWF files
+ */
+object DemosManager {
+ private val demoEntries = mutableStateListOf()
+ private const val DEMOS_DIRECTORY = "demos"
+
+ data class DemoEntry(
+ val name: String,
+ val assetPath: String,
+ val description: String,
+ val category: DemoCategory
+ )
+
+ enum class DemoCategory {
+ ANIMATION,
+ GAME
+ }
+
+ // Map of filename to proper display name and category
+ private val demoMetadata = mapOf(
+ "bitey1.swf" to Pair("Bitey of Brackenwood", DemoCategory.ANIMATION),
+ "saturday_morning_watchmen.swf" to Pair(
+ "Saturday Morning Watchmen",
+ DemoCategory.ANIMATION
+ ),
+ "synj1.swf" to Pair("Synj vs. Horrid Part 1", DemoCategory.ANIMATION),
+ "synj2.swf" to Pair("Synj vs. Horrid Part 2", DemoCategory.ANIMATION),
+ "alien_hominid.swf" to Pair("Alien Hominid", DemoCategory.GAME),
+ "flyguy.swf" to Pair("FlyGuy", DemoCategory.GAME),
+ "marvin_spectrum.swf" to Pair("Marvin Spectrum", DemoCategory.GAME),
+ "wasted_sky.swf" to Pair("Wasted Sky", DemoCategory.GAME)
+ )
+
+ /**
+ * Load demo files from assets
+ */
+ fun loadDemos(context: Context) {
+ try {
+ // Clear previous entries
+ demoEntries.clear()
+
+ // Check if the demos directory exists
+ try {
+ // Get list of files in the demos directory
+ val demoFiles = context.assets.list(DEMOS_DIRECTORY)
+
+ if (demoFiles != null && demoFiles.isNotEmpty()) {
+ Log.d("DemosManager", "Found ${demoFiles.size} files in demos directory")
+
+ // Add each .swf file as a demo entry
+ for (file in demoFiles) {
+ if (file.endsWith(".swf", ignoreCase = true)) {
+ val path = "$DEMOS_DIRECTORY/$file"
+
+ // Get proper name and category from metadata or generate default
+ val (displayName, category) = demoMetadata[file] ?: Pair(
+ file.substringBeforeLast(".").replace("_", " ").capitalize(),
+ DemoCategory.ANIMATION
+ // Default category if unknown
+ )
+
+ // Verify file can be opened
+ try {
+ context.assets.open(path).close()
+
+ // Create appropriate description based on category
+ val description = when (category) {
+ DemoCategory.ANIMATION -> "Flash Animation"
+ DemoCategory.GAME -> "Flash Game"
+ }
+
+ demoEntries.add(
+ DemoEntry(
+ displayName,
+ path,
+ description,
+ category
+ )
+ )
+ Log.d(
+ "DemosManager",
+ "Added demo: $displayName (${category.name}) from $path"
+ )
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Error verifying demo file $path", e)
+ }
+ }
+ }
+ } else {
+ Log.w(
+ "DemosManager",
+ "No files found in demos directory or directory doesn't exist"
+ )
+ }
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Error listing demos directory", e)
+ }
+
+ // If no demos found in assets, log this instead of adding placeholder
+ if (demoEntries.isEmpty()) {
+ Log.w("DemosManager", "No demo SWF files found in assets")
+ }
+
+ Log.d("DemosManager", "Loaded ${demoEntries.size} demo entries")
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Error loading demos", e)
+ }
+ }
+
+ fun getDemoEntries(): List = demoEntries
+
+ fun getAnimationDemos(): List =
+ demoEntries.filter { it.category == DemoCategory.ANIMATION }
+
+ fun getGameDemos(): List = demoEntries.filter { it.category == DemoCategory.GAME }
+
+ /**
+ * Extracts a demo SWF file to a temporary file and returns a Uri to it
+ */
+ fun extractDemoToFile(context: Context, assetPath: String): Uri? {
+ try {
+ // First check if the asset actually exists
+ try {
+ // This will throw an exception if the asset doesn't exist
+ context.assets.open(assetPath).close()
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Demo file $assetPath doesn't exist in assets", e)
+ return null
+ }
+
+ val tempFile = File(context.cacheDir, "demos/${File(assetPath).name}")
+ if (!tempFile.parentFile?.exists()!!) {
+ val dirCreated = tempFile.parentFile?.mkdirs()
+ if (dirCreated != true) {
+ Log.e("DemosManager", "Failed to create parent directory for $tempFile")
+ return null
+ }
+ }
+
+ // If the file already exists, delete it to avoid issues
+ if (tempFile.exists()) {
+ tempFile.delete()
+ }
+
+ try {
+ context.assets.open(assetPath).use { inputStream ->
+ FileOutputStream(tempFile).use { outputStream ->
+ val buffer = ByteArray(1024)
+ var length: Int
+ var totalBytes = 0
+
+ while (inputStream.read(buffer).also { length = it } > 0) {
+ outputStream.write(buffer, 0, length)
+ totalBytes += length
+ }
+
+ outputStream.flush()
+ Log.d(
+ "DemosManager",
+ "Successfully extracted $assetPath to $tempFile ($totalBytes bytes)"
+ )
+ }
+ }
+
+ // Verify the file was created successfully
+ if (!tempFile.exists() || tempFile.length() == 0L) {
+ Log.e("DemosManager", "Extraction completed but file is empty or doesn't exist")
+ return null
+ }
+
+ // Ensure the file is readable
+ if (!tempFile.canRead()) {
+ Log.e("DemosManager", "Extracted file is not readable: $tempFile")
+ return null
+ }
+
+ // Set read permissions for all
+ tempFile.setReadable(true, false)
+
+ // Use FileProvider to create a content:// URI instead of file:// URI
+ val contentUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ tempFile
+ )
+
+ // Grant read permission for the content URI
+ context.grantUriPermission(
+ context.packageName,
+ contentUri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION
+ )
+
+ Log.d("DemosManager", "Created content URI: $contentUri for file: $tempFile")
+ return contentUri
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Error extracting demo file $assetPath", e)
+ return null
+ }
+ } catch (e: Exception) {
+ Log.e("DemosManager", "Error creating temp file", e)
+ return null
+ }
+ }
+
+ // Helper function to capitalize first letter of words
+ private fun String.capitalize(): String {
+ return this.split(" ").joinToString(" ") { word ->
+ if (word.isNotEmpty()) word[0].uppercase() + word.substring(1) else ""
+ }
+ }
+}
diff --git a/app/src/main/java/rs/ruffle/MainActivity.kt b/app/src/main/java/rs/ruffle/MainActivity.kt
index ed1cb17e..6bdaeb7f 100644
--- a/app/src/main/java/rs/ruffle/MainActivity.kt
+++ b/app/src/main/java/rs/ruffle/MainActivity.kt
@@ -3,9 +3,12 @@ package rs.ruffle
import android.content.Intent
import android.net.Uri
import android.os.Bundle
+import android.util.Log
+import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import java.io.File
import rs.ruffle.ui.theme.RuffleTheme
class MainActivity : ComponentActivity() {
@@ -13,6 +16,16 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
+ // Verify demos directory exists in assets or cache
+ verifyDemosDirectory()
+
+ // Load SWF history and favorites data
+ SwfHistoryManager.loadHistory(this)
+ SwfFavoritesManager.loadFavorites(this)
+
+ // Load bundled demo SWF files
+ DemosManager.loadDemos(this)
+
setContent {
RuffleTheme {
RuffleNavHost(openSwf = { openSwf(it) })
@@ -20,6 +33,74 @@ class MainActivity : ComponentActivity() {
}
}
+ private fun verifyDemosDirectory() {
+ try {
+ // Check if demos directory exists
+ val demoFiles = assets.list("demos")
+
+ if (demoFiles == null || demoFiles.isEmpty()) {
+ Log.w(
+ "MainActivity",
+ "No demos directory or it's empty. Creating cache directory for demos."
+ )
+
+ // Create a demos directory in the cache folder so we can still show the demos UI section
+ val demosDir = File(cacheDir, "demos")
+ if (!demosDir.exists()) {
+ demosDir.mkdirs()
+ }
+
+ // Inform the user about missing demos
+ Toast.makeText(
+ this,
+ "No demo Flash files found. The demo section will be empty.",
+ Toast.LENGTH_LONG
+ ).show()
+ } else {
+ Log.d("MainActivity", "Found ${demoFiles.size} files in demos directory")
+
+ // Verify that at least one .swf file exists
+ var hasSWF = false
+ for (file in demoFiles) {
+ if (file.endsWith(".swf", ignoreCase = true)) {
+ try {
+ assets.open("demos/$file").close()
+ hasSWF = true
+ break
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Error verifying demo file demos/$file", e)
+ }
+ }
+ }
+
+ if (!hasSWF) {
+ Log.w("MainActivity", "No .swf files found in demos directory")
+
+ // Inform the user about no SWF files
+ Toast.makeText(
+ this,
+ "Demo section will not work: no valid Flash files found.",
+ Toast.LENGTH_LONG
+ ).show()
+ } else {
+ Log.d(
+ "MainActivity",
+ "Found SWF files in demos directory - demo section will work"
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Log.e("MainActivity", "Error verifying demos directory", e)
+
+ // Inform user about the error
+ Toast.makeText(
+ this,
+ "Error checking for demo files: ${e.localizedMessage}",
+ Toast.LENGTH_LONG
+ ).show()
+ }
+ }
+
private fun openSwf(uri: Uri) {
val intent = Intent(
this@MainActivity,
diff --git a/app/src/main/java/rs/ruffle/PlayerActivity.kt b/app/src/main/java/rs/ruffle/PlayerActivity.kt
index e19fd879..e6f3d84b 100644
--- a/app/src/main/java/rs/ruffle/PlayerActivity.kt
+++ b/app/src/main/java/rs/ruffle/PlayerActivity.kt
@@ -3,10 +3,13 @@ package rs.ruffle
import android.annotation.SuppressLint
import android.content.Intent
import android.content.res.Configuration
+import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Build.VERSION_CODES
import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import android.view.Menu
import android.view.MenuItem
@@ -16,7 +19,9 @@ import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.Button
+import android.widget.ImageButton
import android.widget.PopupMenu
+import android.widget.ProgressBar
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -25,7 +30,6 @@ import androidx.core.view.WindowInsetsControllerCompat
import com.google.androidgamesdk.GameActivity
import java.io.DataInputStream
import java.io.File
-import java.io.IOException
class PlayerActivity : GameActivity() {
@Suppress("unused")
@@ -33,29 +37,167 @@ class PlayerActivity : GameActivity() {
private val swfBytes: ByteArray?
get() {
val uri = intent.data
- if (uri?.scheme == "content") {
- try {
- contentResolver.openInputStream(uri).use { inputStream ->
- if (inputStream == null) {
+ if (uri == null) {
+ Log.e("PlayerActivity", "No URI provided in intent")
+ return null
+ }
+
+ try {
+ Log.d(
+ "PlayerActivity",
+ "Attempting to load SWF from URI: $uri (scheme: ${uri.scheme})"
+ )
+
+ when (uri.scheme) {
+ "content" -> {
+ try {
+ // For content:// URIs (including those from FileProvider)
+ contentResolver.openInputStream(uri).use { inputStream ->
+ if (inputStream == null) {
+ Log.e("PlayerActivity", "Failed to open content URI: $uri")
+ return null
+ }
+
+ // Get the size first
+ val size = inputStream.available()
+ Log.d(
+ "PlayerActivity",
+ "Content URI input stream available size: $size bytes"
+ )
+
+ // Creating byte array of exact size
+ val bytes = ByteArray(size)
+ val dataInputStream = DataInputStream(inputStream)
+ val bytesRead = dataInputStream.read(bytes)
+
+ Log.d(
+ "PlayerActivity",
+ "Successfully read content URI: $uri (read $bytesRead of $size bytes)"
+ )
+
+ if (bytesRead <= 0 || bytesRead < size) {
+ Log.e(
+ "PlayerActivity",
+ "Failed to read complete content from URI: $uri ($bytesRead of $size bytes)"
+ )
+ // Try to continue anyway with what we have
+ }
+
+ // Add to history
+ swfUri = uri
+ addToHistory(uri)
+
+ return bytes
+ }
+ } catch (e: Exception) {
+ Log.e("PlayerActivity", "Error reading content URI: $uri", e)
+ return null
+ }
+ }
+ "file" -> {
+ try {
+ // Handle file:// URIs (although these should be avoided in favor of content:// URIs)
+ val filePath = uri.path
+ if (filePath == null) {
+ Log.e("PlayerActivity", "File URI has no path: $uri")
+ return null
+ }
+
+ val file = File(filePath)
+ if (!file.exists()) {
+ Log.e("PlayerActivity", "File doesn't exist: $filePath")
+ return null
+ }
+
+ if (!file.canRead()) {
+ Log.e("PlayerActivity", "File isn't readable: $filePath")
+ // Try to make it readable
+ file.setReadable(true, false)
+ if (!file.canRead()) {
+ Log.e(
+ "PlayerActivity",
+ "Failed to make file readable: $filePath"
+ )
+ return null
+ }
+ }
+
+ Log.d(
+ "PlayerActivity",
+ "Reading file directly: $filePath (${file.length()} bytes)"
+ )
+
+ file.inputStream().use { inputStream ->
+ val size = inputStream.available()
+ val bytes = ByteArray(size)
+ val dataInputStream = DataInputStream(inputStream)
+ val bytesRead = dataInputStream.read(bytes)
+
+ Log.d(
+ "PlayerActivity",
+ "Successfully read file: $filePath (read $bytesRead of $size bytes)"
+ )
+
+ // Add to history
+ swfUri = uri
+ addToHistory(uri)
+
+ return bytes
+ }
+ } catch (e: Exception) {
+ Log.e("PlayerActivity", "Error reading file URI: $uri", e)
return null
}
- val bytes = ByteArray(inputStream.available())
- val dataInputStream = DataInputStream(inputStream)
- dataInputStream.readFully(bytes)
- return bytes
}
- } catch (ignored: IOException) {
+ else -> {
+ Log.e("PlayerActivity", "Unsupported URI scheme: ${uri.scheme}")
+ return null
+ }
}
+ } catch (e: Exception) {
+ Log.e("PlayerActivity", "Unexpected error loading SWF", e)
+ return null
}
+
return null
}
@Suppress("unused")
// Used by Rust
- private val swfUri: String?
- get() {
- return intent.dataString
+ private fun getSwfUriString(): String? {
+ val uri = intent.data
+ if (uri != null) {
+ // Add to history
+ swfUri = uri
+ addToHistory(uri)
}
+ return intent.dataString
+ }
+
+ @JvmName("getSwfUri")
+ @Suppress("unused")
+ // Used by Rust
+ public fun getSwfUri(): String? {
+ Log.d("PlayerActivity", "getSwfUri() called from native code")
+ if (swfUri == null) {
+ swfUri = intent.data
+ Log.d("PlayerActivity", "getSwfUri() initialized from intent: $swfUri")
+ } else {
+ Log.d("PlayerActivity", "getSwfUri() returning existing uri: $swfUri")
+ }
+ return swfUri?.toString()
+ }
+
+ @JvmName("getSwfUriObject")
+ @Suppress("unused")
+ // Used by Rust (alternative method if needed)
+ public fun getSwfUriObject(): Uri? {
+ Log.d("PlayerActivity", "getSwfUriObject() called")
+ if (swfUri == null) {
+ swfUri = intent.data
+ }
+ return swfUri
+ }
@Suppress("unused")
// Used by Rust
@@ -151,19 +293,73 @@ class PlayerActivity : GameActivity() {
return storageDirPath
}
+ private lateinit var loadingSpinner: ProgressBar
+ private var isContentLoaded = false
+ private lateinit var favoriteButton: ImageButton
+ private var isFavorite = false
+ private var swfUri: Uri? = null
+
override fun onCreateSurfaceView() {
val inflater = layoutInflater
@SuppressLint("InflateParams")
val layout = inflater.inflate(R.layout.keyboard, null) as ConstraintLayout
- contentViewId = ViewCompat.generateViewId()
+ contentViewId = View.generateViewId()
layout.id = contentViewId
setContentView(layout)
mSurfaceView = InputEnabledSurfaceView(this)
mSurfaceView.contentDescription = "Ruffle Player"
+ // Create and add loading spinner with improved styling
+ loadingSpinner = ProgressBar(this, null, android.R.attr.progressBarStyleLarge)
+ loadingSpinner.isIndeterminate = true
+
+ // Set up an overlay background for the spinner to make it stand out
+ val overlayBackground = View(this)
+ overlayBackground.setBackgroundColor(Color.BLACK)
+ overlayBackground.alpha = 0.5f
+
+ val spinnerParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+ spinnerParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
+ spinnerParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
+ spinnerParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID
+ spinnerParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
+ loadingSpinner.layoutParams = spinnerParams
+
+ val overlayParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.MATCH_PARENT,
+ ConstraintLayout.LayoutParams.MATCH_PARENT
+ )
+ overlayBackground.layoutParams = overlayParams
+
+ layout.addView(overlayBackground)
+ layout.addView(loadingSpinner)
+
+ // Add favorite button
+ favoriteButton = ImageButton(this)
+ favoriteButton.setImageResource(android.R.drawable.btn_star_big_off)
+ favoriteButton.setBackgroundResource(android.R.drawable.btn_default)
+
+ val favoriteParams = ConstraintLayout.LayoutParams(
+ ConstraintLayout.LayoutParams.WRAP_CONTENT,
+ ConstraintLayout.LayoutParams.WRAP_CONTENT
+ )
+ favoriteParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID
+ favoriteParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
+ favoriteParams.setMargins(0, 16, 16, 0) // left, top, right, bottom
+ favoriteButton.layoutParams = favoriteParams
+
+ favoriteButton.setOnClickListener {
+ toggleFavorite()
+ }
+
+ layout.addView(favoriteButton)
+
val placeholder = findViewById(R.id.placeholder)
val pars = placeholder.layoutParams as ConstraintLayout.LayoutParams
val parent = placeholder.parent as ViewGroup
@@ -217,6 +413,17 @@ class PlayerActivity : GameActivity() {
window.attributes.layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
+
+ // Set navigation bar transparency for better immersive experience
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ @Suppress("DEPRECATION")
+ window.setDecorFitsSystemWindows(false)
+ window.insetsController?.hide(WindowInsetsCompat.Type.systemBars())
+ } else {
+ @Suppress("DEPRECATION")
+ window.navigationBarColor = Color.TRANSPARENT
+ }
+
// From API 30 onwards, this is the recommended way to hide the system UI, rather than
// using View.setSystemUiVisibility.
val decorView = window.decorView
@@ -228,9 +435,41 @@ class PlayerActivity : GameActivity() {
controller.hide(WindowInsetsCompat.Type.displayCutout())
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+
+ // For devices with display cutouts, ensure content renders properly
+ window.decorView.setOnApplyWindowInsetsListener { view, insets ->
+ view.onApplyWindowInsets(insets)
+ insets
+ }
}
override fun onCreate(savedInstanceState: Bundle?) {
+ // Load history data
+ SwfHistoryManager.loadHistory(this)
+ SwfFavoritesManager.loadFavorites(this)
+
+ // Get intent data and log it for debugging
+ swfUri = intent.data
+ Log.d(
+ "PlayerActivity",
+ "onCreate: intent.data = ${intent.data}, intent.dataString = ${intent.dataString}"
+ )
+
+ if (swfUri != null) {
+ Log.d("PlayerActivity", "Found SWF URI in intent: $swfUri (scheme: ${swfUri!!.scheme})")
+ isFavorite = SwfFavoritesManager.isFavorite(swfUri.toString())
+ } else {
+ Log.e("PlayerActivity", "No SWF URI found in intent!")
+ }
+
+ // Set a fallback timer to hide the loading spinner after a timeout
+ Handler(Looper.getMainLooper()).postDelayed({
+ if (!isContentLoaded) {
+ Log.d("PlayerActivity", "Fallback timer: hiding loading spinner after timeout")
+ hideLoadingSpinner()
+ }
+ }, 10000) // 10 seconds timeout
+
nativeInit { message ->
Log.e("ruffle", "Handling panic: $message")
startActivity(
@@ -269,6 +508,68 @@ class PlayerActivity : GameActivity() {
).hide(WindowInsetsCompat.Type.statusBars())
}
+ // New method to be called from Rust when content is ready
+ @JvmName("onContentReady")
+ @Suppress("unused")
+ // Used by Rust
+ public fun onContentReady() {
+ Log.d("PlayerActivity", "onContentReady() called from native code")
+ runOnUiThread {
+ isContentLoaded = true
+ hideLoadingSpinner()
+ }
+ }
+
+ private fun hideLoadingSpinner() {
+ // Update favorite button with current status
+ if (swfUri != null) {
+ isFavorite = SwfFavoritesManager.isFavorite(swfUri.toString())
+ updateFavoriteButton()
+ }
+
+ // Find and remove the overlay background too
+ val parent = loadingSpinner.parent as? ViewGroup
+ parent?.let {
+ for (i in 0 until it.childCount) {
+ val child = it.getChildAt(i)
+ if (child is View && child.background != null && child.alpha < 1.0f) {
+ Log.d("PlayerActivity", "Hiding overlay background")
+ child.visibility = View.GONE
+ }
+ }
+ }
+
+ Log.d("PlayerActivity", "Hiding loading spinner")
+ loadingSpinner.visibility = View.GONE
+ }
+
+ private fun toggleFavorite() {
+ val uri = swfUri ?: return
+ val displayName = getDisplayNameFromUri(uri) ?: uri.lastPathSegment ?: "Unknown SWF"
+
+ isFavorite = if (SwfFavoritesManager.isFavorite(uri.toString())) {
+ // Remove from favorites
+ SwfFavoritesManager.removeFromFavorites(this, uri.toString())
+ false
+ } else {
+ // Add to favorites
+ SwfFavoritesManager.addToFavorites(this, uri, displayName)
+ true
+ }
+
+ // Update button appearance
+ updateFavoriteButton()
+ }
+
+ private fun updateFavoriteButton() {
+ val iconResource = if (isFavorite) {
+ android.R.drawable.btn_star_big_on
+ } else {
+ android.R.drawable.btn_star_big_off
+ }
+ favoriteButton.setImageResource(iconResource)
+ }
+
companion object {
init {
// load the native activity
@@ -294,4 +595,26 @@ class PlayerActivity : GameActivity() {
fun interface CrashCallback {
fun onCrash(message: String)
}
+
+ // Record SWF file access in history
+ private fun addToHistory(uri: Uri) {
+ val displayName = getDisplayNameFromUri(uri) ?: uri.lastPathSegment ?: "Unknown SWF"
+ SwfHistoryManager.addToHistory(this, uri, displayName)
+ }
+
+ // Get a friendly display name for the SWF file
+ private fun getDisplayNameFromUri(uri: Uri): String? {
+ if (uri.scheme == "content") {
+ val cursor = contentResolver.query(uri, null, null, null, null)
+ cursor?.use {
+ if (it.moveToFirst()) {
+ val displayNameIndex = it.getColumnIndex("_display_name")
+ if (displayNameIndex != -1) {
+ return it.getString(displayNameIndex)
+ }
+ }
+ }
+ }
+ return uri.lastPathSegment
+ }
}
diff --git a/app/src/main/java/rs/ruffle/SelectSwfScreen.kt b/app/src/main/java/rs/ruffle/SelectSwfScreen.kt
index 81692123..f63991cd 100644
--- a/app/src/main/java/rs/ruffle/SelectSwfScreen.kt
+++ b/app/src/main/java/rs/ruffle/SelectSwfScreen.kt
@@ -1,23 +1,46 @@
package rs.ruffle
+import android.content.Intent
import android.content.res.Configuration
import android.net.Uri
+import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.paddingFromBaseline
+import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Clear
+import androidx.compose.material.icons.filled.History
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Star
+import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -38,6 +61,9 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
import rs.ruffle.ui.theme.RuffleTheme
import rs.ruffle.ui.theme.SLIGHTLY_DEEMPHASIZED_ALPHA
@@ -61,12 +87,18 @@ fun SelectSwfRoute(openSwf: (uri: Uri) -> Unit) {
@Composable
fun SelectSwfScreen(openSwf: (uri: Uri) -> Unit) {
+ val scrollState = rememberScrollState()
+
Scaffold { innerPadding ->
Column(
modifier = Modifier
.padding(innerPadding)
- .fillMaxSize(),
- verticalArrangement = Arrangement.Center
+ .fillMaxSize()
+ .verticalScroll(scrollState)
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .imePadding(),
+ verticalArrangement = Arrangement.Top
) {
Column(
modifier = Modifier
@@ -79,11 +111,39 @@ fun SelectSwfScreen(openSwf: (uri: Uri) -> Unit) {
modifier = Modifier
.align(Alignment.CenterHorizontally)
.wrapContentSize(align = Alignment.Center)
- .padding(horizontal = 8.dp, vertical = 20.dp),
+ .padding(horizontal = 16.dp, vertical = 12.dp),
text = stringResource(id = R.string.work_in_progress_warning),
- textAlign = TextAlign.Center
+ textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium
)
+
SelectSwfUrlOrFile(openSwf)
+
+ // Add spacing between sections
+ Spacer(modifier = Modifier.height(16.dp))
+
+ // Display favorites section if there are entries
+ val favoriteEntries = SwfFavoritesManager.getFavoriteEntries()
+ if (favoriteEntries.isNotEmpty()) {
+ FavoritesSection(favoriteEntries, openSwf)
+ // Add spacing between sections
+ Spacer(modifier = Modifier.height(8.dp))
+ }
+
+ // Display bundled demos section
+ DemosSection(openSwf)
+
+ // Add spacing between sections
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Display history section if there are entries
+ val historyEntries = SwfHistoryManager.getHistoryEntries()
+ if (historyEntries.isNotEmpty()) {
+ HistorySection(historyEntries, openSwf)
+ }
+
+ // Add bottom padding for better scrolling experience
+ Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -211,6 +271,399 @@ fun TextFieldError(textError: String) {
}
}
+@Composable
+fun DemosSection(openSwf: (Uri) -> Unit) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ val gameDemos = DemosManager.getGameDemos()
+ val animationDemos = DemosManager.getAnimationDemos()
+
+ if (gameDemos.isNotEmpty() || animationDemos.isNotEmpty()) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp)
+ ) {
+ Text(
+ text = stringResource(id = R.string.demos_title),
+ style = MaterialTheme.typography.titleMedium,
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+
+ // Games section
+ if (gameDemos.isNotEmpty()) {
+ DemoCategorySection(
+ titleResId = R.string.demos_games_title,
+ icon = Icons.Filled.PlayArrow,
+ demos = gameDemos,
+ openSwf = { demo ->
+ val uri = DemosManager.extractDemoToFile(context, demo.assetPath)
+ if (uri != null) {
+ Log.d("SelectSwfScreen", "Opening demo game: $uri (${demo.name})")
+ // Explicitly specify intent data to ensure it's properly passed to PlayerActivity
+ val intent = Intent(context, PlayerActivity::class.java).apply {
+ data = uri
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(intent)
+ } else {
+ // Show error toast
+ android.widget.Toast.makeText(
+ context,
+ "Failed to load game: ${demo.name}",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ )
+ }
+
+ // Add space between sections
+ if (gameDemos.isNotEmpty() && animationDemos.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ // Animations section
+ if (animationDemos.isNotEmpty()) {
+ DemoCategorySection(
+ titleResId = R.string.demos_animations_title,
+ icon = Icons.Filled.Videocam,
+ demos = animationDemos,
+ openSwf = { demo ->
+ val uri = DemosManager.extractDemoToFile(context, demo.assetPath)
+ if (uri != null) {
+ Log.d("SelectSwfScreen", "Opening demo animation: $uri (${demo.name})")
+ // Explicitly specify intent data to ensure it's properly passed to PlayerActivity
+ val intent = Intent(context, PlayerActivity::class.java).apply {
+ data = uri
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(intent)
+ } else {
+ // Show error toast
+ android.widget.Toast.makeText(
+ context,
+ "Failed to load animation: ${demo.name}",
+ android.widget.Toast.LENGTH_SHORT
+ ).show()
+ }
+ }
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun DemoCategorySection(
+ titleResId: Int,
+ icon: androidx.compose.ui.graphics.vector.ImageVector,
+ demos: List,
+ openSwf: (DemosManager.DemoEntry) -> Unit
+) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth(),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = stringResource(id = titleResId),
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = stringResource(id = titleResId),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ LazyColumn(
+ modifier = Modifier
+ .heightIn(max = 200.dp)
+ ) {
+ items(demos) { entry ->
+ DemoItem(entry) {
+ openSwf(entry)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun DemoItem(entry: DemosManager.DemoEntry, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable { onClick() }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.name,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = entry.description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(
+ alpha = SLIGHTLY_DEEMPHASIZED_ALPHA
+ )
+ )
+ }
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = stringResource(id = R.string.open_swf)
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun FavoritesSection(favoriteEntries: List, openSwf: (Uri) -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Filled.Star,
+ contentDescription = stringResource(id = R.string.favorites_title),
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = stringResource(id = R.string.favorites_title),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ val context = androidx.compose.ui.platform.LocalContext.current
+ IconButton(onClick = { SwfFavoritesManager.clearFavorites(context) }) {
+ Icon(
+ imageVector = Icons.Filled.Clear,
+ contentDescription = stringResource(id = R.string.clear_favorites)
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ if (favoriteEntries.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.no_favorites),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier
+ .padding(vertical = 16.dp)
+ .align(Alignment.CenterHorizontally)
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .heightIn(max = 300.dp)
+ ) {
+ items(favoriteEntries) { entry ->
+ FavoriteItem(entry) {
+ openSwf(Uri.parse(entry.uri))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun FavoriteItem(entry: SwfFavoriteEntry, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable { onClick() }
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.displayName,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
+ )
+ if (entry.notes.isNotEmpty()) {
+ Text(
+ text = entry.notes,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(
+ alpha = SLIGHTLY_DEEMPHASIZED_ALPHA
+ )
+ )
+ }
+ }
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = stringResource(id = R.string.open_swf)
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HistorySection(historyEntries: List, openSwf: (Uri) -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(
+ imageVector = Icons.Filled.History,
+ contentDescription = stringResource(id = R.string.history_title),
+ modifier = Modifier.padding(end = 8.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ Text(
+ text = stringResource(id = R.string.history_title),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ val context = androidx.compose.ui.platform.LocalContext.current
+ IconButton(onClick = { SwfHistoryManager.clearHistory(context) }) {
+ Icon(
+ imageVector = Icons.Filled.Clear,
+ contentDescription = stringResource(id = R.string.clear_history)
+ )
+ }
+ }
+
+ HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
+
+ if (historyEntries.isEmpty()) {
+ Text(
+ text = stringResource(id = R.string.no_history),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier
+ .padding(vertical = 16.dp)
+ .align(Alignment.CenterHorizontally)
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .heightIn(max = 300.dp)
+ ) {
+ items(historyEntries) { entry ->
+ HistoryItem(entry) {
+ openSwf(Uri.parse(entry.uri))
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun HistoryItem(entry: SwfHistoryEntry, onClick: () -> Unit) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 4.dp)
+ .clickable { onClick() }
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.displayName,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ Text(
+ text = formatTimestamp(entry.timestamp),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurface.copy(
+ alpha = SLIGHTLY_DEEMPHASIZED_ALPHA
+ )
+ )
+ }
+ IconButton(onClick = onClick) {
+ Icon(
+ imageVector = Icons.Filled.PlayArrow,
+ contentDescription = stringResource(id = R.string.open_swf)
+ )
+ }
+ }
+ }
+}
+
+fun formatTimestamp(timestamp: Long): String {
+ val date = Date(timestamp)
+ val formatter = SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault())
+ return formatter.format(date)
+}
+
@Preview(name = "Select SWF - Light", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Select SWF - Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
diff --git a/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt b/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt
new file mode 100644
index 00000000..9c55b887
--- /dev/null
+++ b/app/src/main/java/rs/ruffle/SwfFavoritesModel.kt
@@ -0,0 +1,97 @@
+package rs.ruffle
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import java.io.File
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+/**
+ * Represents a favorite SWF file entry
+ */
+@Serializable
+data class SwfFavoriteEntry(
+ val uri: String,
+ val displayName: String,
+ val notes: String = "",
+ val timestamp: Long = System.currentTimeMillis()
+) {
+ constructor(uri: Uri, displayName: String, notes: String = "") : this(
+ uri.toString(),
+ displayName,
+ notes,
+ System.currentTimeMillis()
+ )
+}
+
+/**
+ * Manages favorites of SWF files
+ */
+object SwfFavoritesManager {
+ private const val MAX_FAVORITES = 50
+ private const val FAVORITES_FILE_NAME = "swf_favorites.json"
+ private val favoriteEntries = mutableStateListOf()
+ private val json = Json { prettyPrint = true }
+
+ fun getFavoriteEntries(): List = favoriteEntries
+
+ fun addToFavorites(context: Context, uri: Uri, displayName: String, notes: String = "") {
+ // If already exists, remove it first (will be re-added)
+ favoriteEntries.removeAll { it.uri == uri.toString() }
+
+ // Add new entry
+ favoriteEntries.add(0, SwfFavoriteEntry(uri, displayName, notes))
+
+ // Make sure we don't exceed our limit
+ while (favoriteEntries.size > MAX_FAVORITES) {
+ favoriteEntries.removeAt(favoriteEntries.size - 1)
+ }
+
+ // Save to storage
+ saveFavorites(context)
+ }
+
+ fun removeFromFavorites(context: Context, uri: String) {
+ val entryRemoved = favoriteEntries.removeAll { it.uri == uri }
+ if (entryRemoved) {
+ saveFavorites(context)
+ }
+ }
+
+ fun isFavorite(uri: String): Boolean {
+ return favoriteEntries.any { it.uri == uri }
+ }
+
+ fun loadFavorites(context: Context) {
+ try {
+ val file = File(context.filesDir, FAVORITES_FILE_NAME)
+ if (file.exists()) {
+ val jsonString = file.readText()
+ val loadedEntries = json.decodeFromString>(jsonString)
+
+ favoriteEntries.clear()
+ favoriteEntries.addAll(loadedEntries)
+ }
+ } catch (e: Exception) {
+ Log.e("SwfFavoritesManager", "Error loading favorites", e)
+ }
+ }
+
+ private fun saveFavorites(context: Context) {
+ try {
+ val file = File(context.filesDir, FAVORITES_FILE_NAME)
+ val jsonString = json.encodeToString(favoriteEntries)
+ file.writeText(jsonString)
+ } catch (e: Exception) {
+ Log.e("SwfFavoritesManager", "Error saving favorites", e)
+ }
+ }
+
+ fun clearFavorites(context: Context) {
+ favoriteEntries.clear()
+ saveFavorites(context)
+ }
+}
diff --git a/app/src/main/java/rs/ruffle/SwfHistoryModel.kt b/app/src/main/java/rs/ruffle/SwfHistoryModel.kt
new file mode 100644
index 00000000..43b4789f
--- /dev/null
+++ b/app/src/main/java/rs/ruffle/SwfHistoryModel.kt
@@ -0,0 +1,81 @@
+package rs.ruffle
+
+import android.content.Context
+import android.net.Uri
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import java.io.File
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+
+@Serializable
+data class SwfHistoryEntry(
+ val uri: String,
+ val displayName: String,
+ val timestamp: Long
+) {
+ constructor(uri: Uri, displayName: String) : this(
+ uri.toString(),
+ displayName,
+ System.currentTimeMillis()
+ )
+}
+
+/**
+ * Manages history of accessed SWF files
+ */
+object SwfHistoryManager {
+ private const val MAX_HISTORY_ENTRIES = 10
+ private const val HISTORY_FILE_NAME = "swf_history.json"
+ private val historyEntries = mutableStateListOf()
+ private val json = Json { prettyPrint = true }
+
+ fun getHistoryEntries(): List = historyEntries
+
+ fun addToHistory(context: Context, uri: Uri, displayName: String) {
+ // Remove existing entry with the same URI if present
+ historyEntries.removeAll { it.uri == uri.toString() }
+
+ // Add new entry at the beginning
+ historyEntries.add(0, SwfHistoryEntry(uri, displayName))
+
+ // Trim list if too long
+ while (historyEntries.size > MAX_HISTORY_ENTRIES) {
+ historyEntries.removeAt(historyEntries.size - 1)
+ }
+
+ // Save to storage
+ saveHistory(context)
+ }
+
+ fun loadHistory(context: Context) {
+ try {
+ val file = File(context.filesDir, HISTORY_FILE_NAME)
+ if (file.exists()) {
+ val jsonString = file.readText()
+ val loadedEntries = json.decodeFromString>(jsonString)
+
+ historyEntries.clear()
+ historyEntries.addAll(loadedEntries)
+ }
+ } catch (e: Exception) {
+ Log.e("SwfHistoryManager", "Error loading history", e)
+ }
+ }
+
+ private fun saveHistory(context: Context) {
+ try {
+ val file = File(context.filesDir, HISTORY_FILE_NAME)
+ val jsonString = json.encodeToString(historyEntries)
+ file.writeText(jsonString)
+ } catch (e: Exception) {
+ Log.e("SwfHistoryManager", "Error saving history", e)
+ }
+ }
+
+ fun clearHistory(context: Context) {
+ historyEntries.clear()
+ saveHistory(context)
+ }
+}
diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml
index d76a0d57..273c0ee3 100644
--- a/app/src/main/res/values-zh-rCN/strings.xml
+++ b/app/src/main/res/values-zh-rCN/strings.xml
@@ -1,9 +1,29 @@
+
Ruffle
- Ruffle Logo
- 或者
- 打开链接
- 选择本地swf文件
- 请输入网页链接
- 这款应用还在开发中,一些功能可能尚未实现!
+ Ruffle 标志
+ 或
+ 打开 URL
+ 选择 SWF 文件
+ URL
+ 此应用程序正在开发中。有些功能可能尚未实现!
+
+
+ 最近打开
+ 清除历史记录
+ 打开 SWF
+ 暂无历史记录
+
+
+ 演示内容
+ 没有可用的演示内容
+ 游戏
+ 动画
+
+
+ 收藏夹
+ 清空收藏夹
+ 暂无收藏内容
+ 添加到收藏夹
+ 从收藏夹中移除
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 645c0176..3d0121f8 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,4 +6,23 @@
Select a SWF
URL
This app is a work in progress. Some functionality may not be implemented yet!
+
+
+ Recently Opened
+ Clear History
+ Open SWF
+ No history yet
+
+
+ Demo Content
+ No demo content available
+ Games
+ Animations
+
+
+ Favorites
+ Clear Favorites
+ No favorites yet
+ Add to favorites
+ Remove from favorites
\ No newline at end of file
diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 00000000..57fae3a9
--- /dev/null
+++ b/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/java.rs b/src/java.rs
index 3bbde2b5..3c075c13 100644
--- a/src/java.rs
+++ b/src/java.rs
@@ -20,6 +20,7 @@ pub struct JavaInterface {
get_trace_output: JMethodID,
get_loc_in_window: JMethodID,
get_android_data_storage_dir: JMethodID,
+ on_content_ready: JMethodID,
}
static JAVA_INTERFACE: OnceLock = OnceLock::new();
@@ -171,6 +172,18 @@ impl JavaInterface {
.expect("Java interface must have been created via nativeInit()")
}
+ pub fn on_content_ready(env: &mut JNIEnv, this: &JObject) {
+ let result = unsafe {
+ env.call_method_unchecked(
+ this,
+ Self::get().on_content_ready,
+ ReturnType::Primitive(Primitive::Void),
+ &[],
+ )
+ };
+ result.expect("onContentReady() must never throw");
+ }
+
pub fn init(env: &mut JNIEnv, class: &JClass) {
let _ = JAVA_INTERFACE.set(JavaInterface {
get_surface_width: env
@@ -197,6 +210,9 @@ impl JavaInterface {
get_android_data_storage_dir: env
.get_method_id(class, "getAndroidDataStorageDir", "()Ljava/lang/String;")
.expect("getAndroidDataStorageDir must exist"),
+ on_content_ready: env
+ .get_method_id(class, "onContentReady", "()V")
+ .expect("onContentReady must exist"),
});
}
}
diff --git a/src/lib.rs b/src/lib.rs
index e7092fff..5d0745a0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -76,6 +76,10 @@ impl PollRequester for EventSender {
}
}
+// Static flag to track whether we've notified Java that content is ready
+static CONTENT_READY_NOTIFIED: std::sync::atomic::AtomicBool =
+ std::sync::atomic::AtomicBool::new(false);
+
#[tokio::main]
async fn run(app: AndroidApp) {
let mut last_frame_time = Instant::now();
@@ -265,8 +269,24 @@ async fn run(app: AndroidApp) {
player_lock.mutate_with_update_context(|context| {
context.set_root_movie(movie);
});
+ // Reset notification flag when new SWF is loaded
+ CONTENT_READY_NOTIFIED.store(false, std::sync::atomic::Ordering::Relaxed);
+ // Notify Java that content is loaded and ready
+ let (jvm, activity) = get_jvm().unwrap();
+ let mut env = jvm.attach_current_thread().unwrap();
+ JavaInterface::on_content_ready(&mut env, &activity);
+ log::info!("Notified Java that SWF content is ready");
} else {
- player_lock.fetch_root_movie(url, Vec::new(), Box::new(|_| {}))
+ player_lock.fetch_root_movie(url, Vec::new(), Box::new(|_| {
+ // Notify Java that content is loaded and ready when fetched
+ let (_jvm, _activity) = get_jvm().unwrap();
+ if let Ok((jvm, activity)) = get_jvm() {
+ if let Ok(mut env) = jvm.attach_current_thread() {
+ JavaInterface::on_content_ready(&mut env, &activity);
+ log::info!("Notified Java that fetched SWF content is ready");
+ }
+ }
+ }))
}
player_lock.set_is_playing(true); // Desktop player will auto-play.
@@ -458,6 +478,25 @@ async fn run(app: AndroidApp) {
.downcast_mut::()
.unwrap();
audio.recreate_stream_if_needed();
+
+ // Check if content is ready based on player state
+ if player.is_playing() {
+ // Check if we need to notify Java that content is ready
+ let already_notified =
+ CONTENT_READY_NOTIFIED.load(std::sync::atomic::Ordering::Relaxed);
+
+ if !already_notified {
+ log::info!("Content appears to be ready (playing), notifying Java");
+ if let Ok((jvm, activity)) = get_jvm() {
+ if let Ok(mut env) = jvm.attach_current_thread() {
+ JavaInterface::on_content_ready(&mut env, &activity);
+ log::info!("Notified Java that content is ready");
+ CONTENT_READY_NOTIFIED
+ .store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+ }
+ }
+ }
}
} else {
next_frame_time = None;