diff --git a/README.md b/README.md index 456946b..73478a0 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# PSX2 - PlayStation 2 Emulator for Android +# 🎮 PSX2 - PlayStation 2 Emulator for Android -[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://izzy2lost.github.io/PSX2/license.html) [![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://developer.android.com/) [![ARM64](https://img.shields.io/badge/Architecture-ARM64-orange.svg)](https://developer.arm.com/) +[![GitHub](https://img.shields.io/badge/GitHub-izzy2lost%2FPSX2-black.svg)](https://github.com/izzy2lost/PSX2) -PSX2 is a high-performance PlayStation 2 emulator for Android devices, bringing your favorite PS2 games to mobile platforms with enhanced graphics and modern features. +**PSX2** is a high-performance PlayStation 2 emulator for Android devices, bringing your favorite PS2 games to mobile platforms with enhanced graphics and modern features. Experience authentic retro gaming on the go. ## 🎮 About @@ -154,7 +155,7 @@ PSX2 aims for high compatibility with the PlayStation 2 library. Performance var ### License -This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details. +This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details or view it on our [GitHub Pages](https://izzy2lost.github.io/PSX2/license.html). ### Third-Party Licenses diff --git a/app/build.gradle b/app/build.gradle index 3b33dba..f232141 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -115,6 +115,7 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.2.1' implementation 'androidx.documentfile:documentfile:1.1.0' implementation 'androidx.recyclerview:recyclerview:1.4.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.activity:activity:1.10.1' implementation 'androidx.core:core-ktx:1.15.0' implementation 'com.github.bumptech.glide:glide:4.16.0' diff --git a/app/src/main/cpp/AchievementsJNI.cpp b/app/src/main/cpp/AchievementsJNI.cpp index cee3872..714762f 100644 --- a/app/src/main/cpp/AchievementsJNI.cpp +++ b/app/src/main/cpp/AchievementsJNI.cpp @@ -279,4 +279,9 @@ namespace AchievementsJNI env->DeleteLocalRef(j_message); } + JavaVM* GetJavaVM() + { + return s_jvm; + } + } // namespace AchievementsJNI diff --git a/app/src/main/cpp/AchievementsJNI.h b/app/src/main/cpp/AchievementsJNI.h index b2153c3..834e67c 100644 --- a/app/src/main/cpp/AchievementsJNI.h +++ b/app/src/main/cpp/AchievementsJNI.h @@ -41,4 +41,7 @@ namespace AchievementsJNI /// Show a generic notification void ShowNotification(const char* message, int duration); + /// Get the JavaVM instance (for internal use) + JavaVM* GetJavaVM(); + } // namespace AchievementsJNI diff --git a/app/src/main/cpp/AchievementsNativeMethods.cpp b/app/src/main/cpp/AchievementsNativeMethods.cpp index 4fcedd0..1bc2133 100644 --- a/app/src/main/cpp/AchievementsNativeMethods.cpp +++ b/app/src/main/cpp/AchievementsNativeMethods.cpp @@ -7,6 +7,7 @@ #include "rc_client.h" #include "pcsx2/Achievements.h" #include "pcsx2/Config.h" +#include "pcsx2/Host.h" #include "common/Console.h" #include "common/Error.h" @@ -241,4 +242,28 @@ Java_com_izzy2lost_psx2_NativeApp_achievementsSetHardcoreMode(JNIEnv* env, jclas __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Hardcore mode will apply on next game load"); } +JNIEXPORT void JNICALL +Java_com_izzy2lost_psx2_NativeApp_achievementsLoginWithToken(JNIEnv* env, jclass clazz, + jstring username, jstring token) +{ + if (!username || !token) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Login with token called with null username or token"); + return; + } + + const char* username_str = env->GetStringUTFChars(username, nullptr); + const char* token_str = env->GetStringUTFChars(token, nullptr); + + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Attempting token login for user: %s", username_str); + + // Store credentials using Host functions + Host::SetBaseStringSettingValue("Achievements", "Username", username_str); + Host::SetBaseStringSettingValue("Achievements", "Token", token_str); + __android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Token login credentials set"); + + env->ReleaseStringUTFChars(username, username_str); + env->ReleaseStringUTFChars(token, token_str); +} + } // extern "C" diff --git a/app/src/main/cpp/native-lib.cpp b/app/src/main/cpp/native-lib.cpp index 2cd0cf4..0bba203 100644 --- a/app/src/main/cpp/native-lib.cpp +++ b/app/src/main/cpp/native-lib.cpp @@ -1378,7 +1378,95 @@ Java_com_izzy2lost_psx2_NativeApp_getImageSlot(JNIEnv *env, jclass clazz, jint p void Host::CommitBaseSettingChanges() { - // nothing to save, we're all in memory + // Save achievements settings to Android SharedPreferences + // This is called after login to persist the token + + auto lock = Host::GetSettingsLock(); + SettingsInterface* si = Host::GetSettingsInterface(); + if (!si) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "No settings interface available"); + return; + } + + // Get achievements credentials from settings + std::string username = si->GetStringValue("Achievements", "Username", ""); + std::string token = si->GetStringValue("Achievements", "Token", ""); + std::string loginTimestamp = si->GetStringValue("Achievements", "LoginTimestamp", ""); + + if (username.empty() && token.empty()) + { + // Nothing to save + return; + } + + __android_log_print(ANDROID_LOG_INFO, "PCSX2", "Saving achievements credentials to SharedPreferences"); + + // Call the Java method to save to SharedPreferences + // We'll use JNI to call NativeApp.saveAchievementsCredentials() + JavaVM* jvm = AchievementsJNI::GetJavaVM(); + if (!jvm) + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "JavaVM not available"); + return; + } + + JNIEnv* env = nullptr; + bool attached = false; + + // Get JNI environment + if (jvm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) + { + // Try to attach current thread + if (jvm->AttachCurrentThread(&env, nullptr) == JNI_OK) + { + attached = true; + } + else + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Failed to attach thread to JVM"); + return; + } + } + + // Find the NativeApp class and saveAchievementsCredentials method + jclass nativeAppClass = env->FindClass("com/izzy2lost/psx2/NativeApp"); + if (nativeAppClass) + { + jmethodID saveMethod = env->GetStaticMethodID(nativeAppClass, "saveAchievementsCredentials", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"); + if (saveMethod) + { + jstring jUsername = env->NewStringUTF(username.c_str()); + jstring jToken = env->NewStringUTF(token.c_str()); + jstring jTimestamp = env->NewStringUTF(loginTimestamp.c_str()); + + env->CallStaticVoidMethod(nativeAppClass, saveMethod, jUsername, jToken, jTimestamp); + + env->DeleteLocalRef(jUsername); + env->DeleteLocalRef(jToken); + env->DeleteLocalRef(jTimestamp); + + __android_log_print(ANDROID_LOG_INFO, "PCSX2", "Achievements credentials saved successfully"); + } + else + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Could not find saveAchievementsCredentials method"); + env->ExceptionClear(); + } + env->DeleteLocalRef(nativeAppClass); + } + else + { + __android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Could not find NativeApp class"); + env->ExceptionClear(); + } + + // Detach thread if we attached it + if (attached) + { + jvm->DetachCurrentThread(); + } } void Host::LoadSettings(SettingsInterface& si, std::unique_lock& lock) diff --git a/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java index 5fa312e..5d42d72 100644 --- a/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/AchievementsDialogFragment.java @@ -282,7 +282,7 @@ public class AchievementsDialogFragment extends DialogFragment { editor.putString(PREF_USERNAME, username); editor.putBoolean(PREF_REMEMBER_ME, rememberMe); - // Save password only if remember me is checked + // Save password only if remember me is checked (for fallback) if (rememberMe) { editor.putString(PREF_SAVED_PASSWORD, password); } else { @@ -296,6 +296,9 @@ public class AchievementsDialogFragment extends DialogFragment { NativeApp.achievementsLogin(username, password); android.util.Log.d("Achievements", "Native login call completed"); + // The token will be automatically saved by Host::CommitBaseSettingChanges() + // which is called from the native login callback + // Wait a bit for the login to process try { Thread.sleep(2000); @@ -309,7 +312,7 @@ public class AchievementsDialogFragment extends DialogFragment { android.util.Log.d("Achievements", "After login - isActive: " + isActive); if (isActive) { - Toast.makeText(requireContext(), "Login successful!", Toast.LENGTH_SHORT).show(); + Toast.makeText(requireContext(), "Login successful! Token saved for auto-login.", Toast.LENGTH_SHORT).show(); } else { Toast.makeText(requireContext(), "Login may have failed - check logs", Toast.LENGTH_LONG).show(); } @@ -323,9 +326,11 @@ public class AchievementsDialogFragment extends DialogFragment { private void performLogout() { NativeApp.achievementsLogout(); - // Clear saved password on logout + // Clear saved credentials including token getPrefs().edit() .remove(PREF_SAVED_PASSWORD) + .remove("token") + .remove("login_timestamp") .putBoolean(PREF_REMEMBER_ME, false) .apply(); diff --git a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java index b8fa468..66e9659 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GameSettingsDialogFragment.java @@ -29,6 +29,7 @@ public class GameSettingsDialogFragment extends DialogFragment { // File picker state private ActivityResultLauncher mPnachPicker; + private ActivityResultLauncher mCoverPicker; private boolean mImportAsCheats = true; public static GameSettingsDialogFragment newInstance(String gameTitle, String gameUri, String gameSerial, String gameCrc) { @@ -114,6 +115,96 @@ public class GameSettingsDialogFragment extends DialogFragment { } }); } + + // Register cover picker + if (mCoverPicker == null) { + mCoverPicker = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> { + try { + if (result.getResultCode() != Activity.RESULT_OK) return; + Intent data = result.getData(); if (data == null) return; + Uri uri = data.getData(); if (uri == null) return; + Bundle args = getArguments(); + String gameSerial = args != null ? args.getString(ARG_GAME_SERIAL, "") : ""; + if (gameSerial == null || gameSerial.isEmpty()) { + try { gameSerial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {} + } + if (gameSerial == null || gameSerial.isEmpty()) { + android.widget.Toast.makeText(ctx, "Serial unknown; cannot set cover", android.widget.Toast.LENGTH_SHORT).show(); + return; + } + + final String serial = gameSerial; + android.widget.Toast.makeText(ctx, "Processing cover...", android.widget.Toast.LENGTH_SHORT).show(); + + // Process in background to avoid blocking UI + new Thread(() -> { + try { + // Load and resize the image + android.content.ContentResolver cr = ctx.getContentResolver(); + java.io.InputStream in = cr.openInputStream(uri); + if (in == null) throw new Exception("Cannot open image"); + + android.graphics.Bitmap original = android.graphics.BitmapFactory.decodeStream(in); + in.close(); + if (original == null) throw new Exception("Cannot decode image"); + + // Resize to standard cover dimensions (567x878 for PS2 covers) + int targetWidth = 567; + int targetHeight = 878; + android.graphics.Bitmap resized = android.graphics.Bitmap.createScaledBitmap( + original, targetWidth, targetHeight, true); + original.recycle(); + + // Save to SAF location only + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, serial + ".png"); + if (existing != null && existing.exists()) { + existing.delete(); + } + androidx.documentfile.provider.DocumentFile newFile = SafManager.createChild(ctx, new String[]{"covers"}, serial + ".png", "image/png"); + if (newFile != null) { + java.io.OutputStream out = ctx.getContentResolver().openOutputStream(newFile.getUri(), "w"); + if (out != null) { + resized.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out); + out.flush(); + out.close(); + } + } + resized.recycle(); + + // Mark as custom cover + ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + .edit() + .putBoolean("custom_cover:" + serial, true) + .apply(); + + // Clear Glide cache on background thread + try { + com.bumptech.glide.Glide.get(ctx).clearDiskCache(); + } catch (Throwable ignored) {} + + // Show success message and clear memory cache on UI thread + if (getActivity() != null && !getActivity().isFinishing()) { + getActivity().runOnUiThread(() -> { + try { + com.bumptech.glide.Glide.get(ctx).clearMemory(); + } catch (Throwable ignored) {} + android.widget.Toast.makeText(ctx, "Cover saved for " + serial, android.widget.Toast.LENGTH_SHORT).show(); + }); + } + } catch (Exception e) { + android.util.Log.e("GameSettings", "Error saving cover: " + e.getMessage(), e); + if (getActivity() != null && !getActivity().isFinishing()) { + getActivity().runOnUiThread(() -> { + android.widget.Toast.makeText(ctx, "Failed to save cover: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show(); + }); + } + } + }).start(); + } catch (Exception e) { + android.widget.Toast.makeText(ctx, "Cover import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show(); + } + }); + } Bundle args = getArguments(); String gameTitle = args != null ? args.getString(ARG_GAME_TITLE, "Unknown Game") : "Unknown Game"; @@ -391,6 +482,43 @@ public class GameSettingsDialogFragment extends DialogFragment { .show(); }); } + + // Custom Cover button wiring + com.google.android.material.button.MaterialButton btnCustomCover = view.findViewById(R.id.btn_set_custom_cover); + if (btnCustomCover != null) { + btnCustomCover.setOnClickListener(v -> { + // Check if this game has a custom cover + android.content.SharedPreferences prefs = ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE); + boolean hasCustomCover = prefs.getBoolean("custom_cover:" + gameSerial, false); + + if (hasCustomCover) { + // Show options: Set New or Delete Custom + final String[] choices = new String[]{"Set New Custom Cover", "Delete Custom Cover"}; + new MaterialAlertDialogBuilder(ctx, + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog) + .setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Custom Cover")) + .setItems(choices, (dlg, which) -> { + if (which == 0) { + // Set new custom cover + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("image/*"); + mCoverPicker.launch(intent); + } else { + // Delete custom cover + deleteCustomCover(ctx, gameSerial); + } + }) + .show(); + } else { + // No custom cover exists, directly open picker + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType("image/*"); + mCoverPicker.launch(intent); + } + }); + } return builder.create(); } @@ -500,4 +628,116 @@ public class GameSettingsDialogFragment extends DialogFragment { } catch (Throwable ignored) { } } + + private void importCustomCover(Context ctx, Uri sourceUri, String gameSerial) throws Exception { + // Load the image + android.content.ContentResolver cr = ctx.getContentResolver(); + java.io.InputStream in = cr.openInputStream(sourceUri); + if (in == null) throw new Exception("Cannot open image"); + + android.graphics.Bitmap originalBitmap = android.graphics.BitmapFactory.decodeStream(in); + in.close(); + if (originalBitmap == null) throw new Exception("Invalid image format"); + + // Resize to standard PS2 cover dimensions (567x878) + final int TARGET_WIDTH = 567; + final int TARGET_HEIGHT = 878; + android.graphics.Bitmap resizedBitmap = android.graphics.Bitmap.createScaledBitmap( + originalBitmap, TARGET_WIDTH, TARGET_HEIGHT, true); + originalBitmap.recycle(); + + // Save to both locations + String fileName = gameSerial + ".png"; + + // Save to internal storage + java.io.File baseDir = ctx.getExternalFilesDir("covers"); + if (baseDir == null) baseDir = new java.io.File(ctx.getFilesDir(), "covers"); + if (!baseDir.exists()) baseDir.mkdirs(); + java.io.File outFile = new java.io.File(baseDir, fileName); + java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile); + resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos); + fos.flush(); + fos.close(); + + // Save to SAF location if set + android.net.Uri dataRoot = SafManager.getDataRootUri(ctx); + if (dataRoot != null) { + try { + // Delete existing if present + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, fileName); + if (existing != null && existing.exists()) { + existing.delete(); + } + + // Create new file + androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"covers"}, fileName, "image/png"); + if (target != null) { + java.io.OutputStream os = cr.openOutputStream(target.getUri(), "w"); + if (os != null) { + resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, os); + os.flush(); + os.close(); + } + } + } catch (Exception e) { + android.util.Log.w("GameSettings", "Failed to save to SAF: " + e.getMessage()); + } + } + + resizedBitmap.recycle(); + } + + private void deleteCustomCover(Context ctx, String gameSerial) { + if (gameSerial == null || gameSerial.isEmpty()) { + android.widget.Toast.makeText(ctx, "Cannot delete cover: serial unknown", android.widget.Toast.LENGTH_SHORT).show(); + return; + } + + new Thread(() -> { + try { + String fileName = gameSerial + ".png"; + boolean deleted = false; + + // Delete from SAF location + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, fileName); + if (existing != null && existing.exists()) { + deleted = existing.delete(); + } + + // Clear custom cover flag + ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE) + .edit() + .putBoolean("custom_cover:" + gameSerial, false) + .apply(); + + // Clear Glide cache + try { + com.bumptech.glide.Glide.get(ctx).clearDiskCache(); + } catch (Throwable ignored) {} + + // Show result on UI thread + final boolean success = deleted; + if (getActivity() != null && !getActivity().isFinishing()) { + getActivity().runOnUiThread(() -> { + try { + com.bumptech.glide.Glide.get(ctx).clearMemory(); + } catch (Throwable ignored) {} + + if (success) { + android.widget.Toast.makeText(ctx, "Custom cover deleted for " + gameSerial, android.widget.Toast.LENGTH_SHORT).show(); + } else { + android.widget.Toast.makeText(ctx, "No custom cover found to delete", android.widget.Toast.LENGTH_SHORT).show(); + } + }); + } + } catch (Exception e) { + android.util.Log.e("GameSettings", "Error deleting custom cover: " + e.getMessage(), e); + if (getActivity() != null && !getActivity().isFinishing()) { + getActivity().runOnUiThread(() -> { + android.widget.Toast.makeText(ctx, "Failed to delete cover: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show(); + }); + } + } + }).start(); + } } diff --git a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java index b44e972..b910013 100644 --- a/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java +++ b/app/src/main/java/com/izzy2lost/psx2/GamesCoverDialogFragment.java @@ -26,6 +26,7 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.PagerSnapHelper; import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.google.android.material.button.MaterialButtonToggleGroup; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import androidx.core.view.GravityCompat; @@ -47,8 +48,8 @@ public class GamesCoverDialogFragment extends DialogFragment { private String[] uris; private String[] coverUrls; private String[] localPaths; - private String[] origTitles; - private String[] origUris; + String[] origTitles; // Package-private for GameSettingsDialogFragment access + String[] origUris; // Package-private for GameSettingsDialogFragment access private String[] origCoverUrls; private String[] origLocalPaths; private RecyclerView rv; @@ -119,13 +120,7 @@ public class GamesCoverDialogFragment extends DialogFragment { // The layout file is cached when dialog is created, so we need to recreate the dialog // to get the correct layout for the new orientation - dismiss(); - - // Recreate the dialog with the correct layout for new orientation - if (getParentFragmentManager() != null) { - GamesCoverDialogFragment newDialog = GamesCoverDialogFragment.newInstance(origTitles, origUris); - newDialog.show(getParentFragmentManager(), getTag()); - } + recreateDialogWithCurrentState(); } @NonNull @@ -153,6 +148,12 @@ public class GamesCoverDialogFragment extends DialogFragment { // Post to re-assert immersive after layout try { root.post(this::forceDialogImmersive); } catch (Throwable ignored) {} + // Disable SwipeRefreshLayout since it conflicts with horizontal scrolling + SwipeRefreshLayout swipeRefresh = root.findViewById(R.id.swipe_refresh); + if (swipeRefresh != null) { + swipeRefresh.setEnabled(false); + } + rv = root.findViewById(R.id.recycler_covers); rv.setHasFixedSize(true); llm = new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false); @@ -213,21 +214,12 @@ public class GamesCoverDialogFragment extends DialogFragment { serial = buildSerialFromUri(uris[i]); } coverUrls[i] = buildCoverUrlFromSerial(serial); - // Prefer SAF content URI if data root is set and the file already exists. - // Do NOT pre-create empty placeholder files here (they cause confusing zero-byte files - // alongside downloaded covers). If the SAF file doesn't exist yet, fall back to a - // filesystem path and let the downloader create the SAF file when performing the - // actual download (startDownloadCovers will create the SAF child as needed). - android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext()); - if (dataRoot != null) { - androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); - if (f != null && f.exists()) { - localPaths[i] = f.getUri().toString(); - } else { - // Don't create an empty file here; use the file-system fallback path instead. - localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath(); - } + // Get existing SAF file URI if it exists, otherwise use placeholder path + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); + if (existing != null && existing.exists()) { + localPaths[i] = existing.getUri().toString(); } else { + // Use file path as placeholder - will be replaced when downloaded localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath(); } } @@ -422,19 +414,8 @@ public class GamesCoverDialogFragment extends DialogFragment { // Refresh drawer settings before opening refreshDialogDrawerSettings(); androidx.drawerlayout.widget.DrawerLayout drawer = root.findViewById(R.id.dlg_drawer_layout); - if (drawer != null) { - // Defer the open to avoid layout/reentrancy races similar to activity path - drawer.post(() -> { - try { - drawer.openDrawer(GravityCompat.START); - } catch (Throwable t) { - android.util.Log.e("GamesCoverDialog", "Error opening dialog drawer (posted): " + t.getMessage()); - } - }); - } - } catch (Throwable t) { - android.util.Log.e("GamesCoverDialog", "Error scheduling dialog drawer open: " + t.getMessage()); - } + if (drawer != null) drawer.openDrawer(GravityCompat.START); + } catch (Throwable ignored) {} }); // Setup drawer listener for pause/resume tracking @@ -572,9 +553,29 @@ public class GamesCoverDialogFragment extends DialogFragment { } catch (Throwable ignored) {} View btnDownload = root.findViewById(R.id.btn_download); if (btnDownload != null) btnDownload.setOnClickListener(v -> startDownloadCovers()); + + View btnRefresh = root.findViewById(R.id.btn_refresh); + if (btnRefresh != null) btnRefresh.setOnClickListener(v -> refreshDialog()); return root; } + + // Refresh dialog like orientation change does + private void refreshDialog() { + recreateDialogWithCurrentState(); + } + + // Helper method to recreate dialog with current state (used by both orientation change and refresh button) + private void recreateDialogWithCurrentState() { + dismiss(); + if (getParentFragmentManager() != null) { + GamesCoverDialogFragment newDialog = GamesCoverDialogFragment.newInstance(origTitles, origUris); + // Preserve current sort mode and search query + newDialog.sortMode = this.sortMode; + newDialog.query = this.query; + newDialog.show(getParentFragmentManager(), getTag()); + } + } @Override public void onStart() { @@ -629,18 +630,8 @@ public class GamesCoverDialogFragment extends DialogFragment { // Refresh drawer settings before opening refreshDialogDrawerSettings(); androidx.drawerlayout.widget.DrawerLayout drawer = root.findViewById(R.id.dlg_drawer_layout); - if (drawer != null) { - drawer.post(() -> { - try { - drawer.openDrawer(androidx.core.view.GravityCompat.START); - } catch (Throwable t) { - android.util.Log.e("GamesCoverDialog", "Error opening dialog drawer (posted): " + t.getMessage()); - } - }); - } - } catch (Throwable t) { - android.util.Log.e("GamesCoverDialog", "Error scheduling dialog drawer open: " + t.getMessage()); - } + if (drawer != null) drawer.openDrawer(androidx.core.view.GravityCompat.START); + } catch (Throwable ignored) {} } private void setupDialogDrawerSettings(View header) { @@ -1001,6 +992,44 @@ public class GamesCoverDialogFragment extends DialogFragment { } private void startDownloadCovers() { + // Check if any games have custom covers + SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE); + java.util.ArrayList customCoverGames = new java.util.ArrayList<>(); + + for (int i = 0; i < uris.length; i++) { + try { + String serial = prefs.getString("serial:" + uris[i], null); + if (serial == null || serial.isEmpty()) { + try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {} + } + if (serial == null || serial.isEmpty()) { + serial = buildSerialFromUri(uris[i]); + } + serial = normalizeSerial(serial); + + if (prefs.getBoolean("custom_cover:" + serial, false)) { + customCoverGames.add(titles[i]); + } + } catch (Exception ignored) {} + } + + // If custom covers exist, ask user what to do + if (!customCoverGames.isEmpty()) { + String message = "Found " + customCoverGames.size() + " game(s) with custom covers.\n\nWhat would you like to do?"; + new MaterialAlertDialogBuilder(requireContext(), + com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog) + .setTitle("Custom Covers Detected") + .setMessage(message) + .setNegativeButton("Cancel", null) + .setNeutralButton("Skip Custom", (d, w) -> downloadCoversInternal(true)) + .setPositiveButton("Delete All Custom", (d, w) -> deleteCustomCovers()) + .show(); + } else { + downloadCoversInternal(false); + } + } + + private void downloadCoversInternal(boolean skipCustomCovers) { Toast.makeText(requireContext(), "Downloading covers in background", Toast.LENGTH_SHORT).show(); new Thread(() -> { SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE); @@ -1028,24 +1057,121 @@ public class GamesCoverDialogFragment extends DialogFragment { int total = coverUrls.length; int ok = 0; - java.io.File dir = getCoversDir(); - if (!dir.exists()) dir.mkdirs(); + int skipped = 0; for (int i = 0; i < total; i++) { String url = coverUrls[i]; String outPath = localPaths[i]; - if (isFileValid(outPath)) { ok++; continue; } + + // Get serial for this game + String serial = null; try { - if (downloadToTarget(url, outPath)) ok++; + serial = prefs.getString("serial:" + uris[i], null); + if (serial == null || serial.isEmpty()) { + try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {} + } + if (serial == null || serial.isEmpty()) { + serial = buildSerialFromUri(uris[i]); + } + serial = normalizeSerial(serial); + } catch (Exception ignored) {} + + // Check if this is a custom cover and should be skipped + if (skipCustomCovers && serial != null) { + if (prefs.getBoolean("custom_cover:" + serial, false)) { + skipped++; + if (isFileValid(outPath)) ok++; + continue; + } + } + + // Check if file exists in SAF + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); + if (existing != null && existing.exists() && existing.length() > 0) { + ok++; + continue; + } + + try { + if (downloadToTarget(url, serial)) { + ok++; + // Update localPaths with SAF URI after successful download + androidx.documentfile.provider.DocumentFile downloaded = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); + if (downloaded != null && downloaded.exists()) { + localPaths[i] = downloaded.getUri().toString(); + } + // Clear custom flag after successful download + if (serial != null) { + editor.putBoolean("custom_cover:" + serial, false); + } + } } catch (Exception ignored) { } } + editor.apply(); + + // Cleanup: delete any 0-byte PNG files in covers folder + cleanupEmptyCovers(); + final int downloaded = ok; + final int skippedCount = skipped; if (isAdded()) requireActivity().runOnUiThread(() -> { - Toast.makeText(requireContext(), "Covers ready: " + downloaded + "/" + total, Toast.LENGTH_SHORT).show(); + String msg = "Covers ready: " + downloaded + "/" + total; + if (skippedCount > 0) { + msg += " (" + skippedCount + " custom skipped)"; + } + Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show(); if (adapter != null) adapter.notifyDataSetChanged(); }); }).start(); } + private void deleteCustomCovers() { + Toast.makeText(requireContext(), "Deleting custom covers...", Toast.LENGTH_SHORT).show(); + new Thread(() -> { + SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + int deletedCount = 0; + + for (int i = 0; i < uris.length; i++) { + try { + String serial = prefs.getString("serial:" + uris[i], null); + if (serial == null || serial.isEmpty()) { + try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {} + } + if (serial == null || serial.isEmpty()) { + serial = buildSerialFromUri(uris[i]); + } + serial = normalizeSerial(serial); + + // Check if this game has a custom cover + if (prefs.getBoolean("custom_cover:" + serial, false)) { + // Delete the custom cover file + try { + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); + if (existing != null && existing.exists()) { + existing.delete(); + deletedCount++; + } + } catch (Exception e) { + android.util.Log.w("GamesCoverDialog", "Error deleting custom cover for " + serial + ": " + e.getMessage()); + } + + // Clear the custom cover flag + editor.putBoolean("custom_cover:" + serial, false); + } + } catch (Exception e) { + android.util.Log.w("GamesCoverDialog", "Error processing game " + i + ": " + e.getMessage()); + } + } + editor.apply(); + + final int deleted = deletedCount; + if (isAdded()) requireActivity().runOnUiThread(() -> { + Toast.makeText(requireContext(), "Deleted " + deleted + " custom cover(s)", Toast.LENGTH_SHORT).show(); + if (adapter != null) adapter.notifyDataSetChanged(); + }); + }).start(); + } + private static String serialFromUrl(String url) { if (url == null) return null; int slash = url.lastIndexOf('/'); @@ -1066,7 +1192,7 @@ public class GamesCoverDialogFragment extends DialogFragment { return f.exists() && f.length() > 0; } - private boolean downloadToTarget(String urlStr, String outPath) throws Exception { + private boolean downloadToTarget(String urlStr, String serial) throws Exception { java.net.URL url = new java.net.URL(urlStr); java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection(); conn.setConnectTimeout(10000); @@ -1076,31 +1202,44 @@ public class GamesCoverDialogFragment extends DialogFragment { int code = conn.getResponseCode(); if (code != 200) { conn.disconnect(); return false; } java.io.InputStream in = conn.getInputStream(); - if (outPath.startsWith("content://")) { - android.net.Uri uri = android.net.Uri.parse(outPath); - try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(uri, "w")) { - if (os == null) { conn.disconnect(); return false; } - byte[] buf = new byte[8192]; - int n; - while ((n = in.read(buf)) != -1) os.write(buf, 0, n); - os.flush(); - } - in.close(); - } else { - java.io.File outFile = new java.io.File(outPath); - java.io.File parent = outFile.getParentFile(); - if (parent != null && !parent.exists()) parent.mkdirs(); - java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile); + + // Delete existing file if present + androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png"); + if (existing != null && existing.exists()) { + existing.delete(); + } + + // Create new file in SAF + androidx.documentfile.provider.DocumentFile newFile = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png"); + if (newFile == null) { conn.disconnect(); in.close(); return false; } + + try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(newFile.getUri(), "w")) { + if (os == null) { conn.disconnect(); in.close(); return false; } byte[] buf = new byte[8192]; int n; - while ((n = in.read(buf)) != -1) fos.write(buf, 0, n); - fos.flush(); - fos.close(); - in.close(); + while ((n = in.read(buf)) != -1) os.write(buf, 0, n); + os.flush(); } + in.close(); conn.disconnect(); return true; } + + private void cleanupEmptyCovers() { + try { + // Cleanup SAF folder only + androidx.documentfile.provider.DocumentFile coversDir = SafManager.getChild(requireContext(), new String[]{"covers"}, null); + if (coversDir != null && coversDir.isDirectory()) { + for (androidx.documentfile.provider.DocumentFile file : coversDir.listFiles()) { + if (file.isFile() && file.getName() != null && file.getName().endsWith(".png") && file.length() == 0) { + file.delete(); + } + } + } + } catch (Throwable ignored) {} + } + + private void refreshDialogDrawerSettings() { try { diff --git a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java index f7167ea..a5cf6d1 100644 --- a/app/src/main/java/com/izzy2lost/psx2/MainActivity.java +++ b/app/src/main/java/com/izzy2lost/psx2/MainActivity.java @@ -456,6 +456,9 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF // Initialize RetroAchievements RetroAchievementsManager.initialize(this); + + // Load and auto-login with saved credentials if available + NativeApp.loadAndLoginAchievements(); // Initialize controller input handler mControllerInputHandler = new ControllerInputHandler(this); @@ -574,20 +577,13 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF if (btn_settings != null) { btn_settings.setOnClickListener(v -> { try { - // Open the drawer via post() to avoid reentrancy/layout timing issues - // (programmatic open can sometimes race with layout/insets handling) + // Just open the drawer - let the drawer listener handle pausing DrawerLayout drawer = findViewById(R.id.drawer_layout); if (drawer != null) { - drawer.post(() -> { - try { - drawer.openDrawer(androidx.core.view.GravityCompat.START); - } catch (Throwable t) { - android.util.Log.e("MainActivity", "Error opening settings drawer (posted): " + t.getMessage()); - } - }); + drawer.openDrawer(androidx.core.view.GravityCompat.START); } } catch (Throwable t) { - android.util.Log.e("MainActivity", "Error scheduling settings drawer open: " + t.getMessage()); + android.util.Log.e("MainActivity", "Error opening settings drawer: " + t.getMessage()); } }); } @@ -692,20 +688,6 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } catch (Throwable ignored) {} }); } - - // Achievements button - View btnAchievements = header.findViewById(R.id.drawer_btn_achievements); - if (btnAchievements != null) { - btnAchievements.setOnClickListener(v -> { - try { - android.util.Log.d("MainActivity", "Achievements button clicked"); - AchievementsDialogFragment dialog = AchievementsDialogFragment.newInstance(); - dialog.show(getSupportFragmentManager(), "achievements_dialog"); - } catch (Throwable e) { - android.util.Log.e("MainActivity", "Error showing achievements dialog: " + e.getMessage()); - } - }); - } // Setup drawer settings controls to mirror quick actions setupDrawerSettings(header); @@ -1607,36 +1589,6 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.pause_circle_24px)); } }); - - // Auto-initialize and login to achievements if enabled - SharedPreferences prefs = getSharedPreferences("RetroAchievements", MODE_PRIVATE); - boolean achievementsEnabled = prefs.getBoolean("enabled", false); - if (achievementsEnabled) { - String username = prefs.getString("username", ""); - boolean rememberMe = prefs.getBoolean("remember_me", false); - String savedPassword = prefs.getString("saved_password", ""); - - new Thread(() -> { - try { - Thread.sleep(3000); // Wait 3 seconds for game to start - - // Initialize if not already active - if (!NativeApp.achievementsIsActive()) { - android.util.Log.d("Achievements", "Auto-initializing achievements for game"); - NativeApp.achievementsInitialize(); - Thread.sleep(500); // Wait for initialization - } - - // Auto-login if credentials are saved - if (!username.isEmpty() && rememberMe && !savedPassword.isEmpty()) { - android.util.Log.d("Achievements", "Auto-logging in as: " + username); - NativeApp.achievementsLogin(username, savedPassword); - } - } catch (Exception e) { - android.util.Log.e("Achievements", "Failed to auto-initialize/login: " + e.getMessage()); - } - }).start(); - } } } @@ -2423,9 +2375,9 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } }) .setNegativeButton("View License", (dialog, which) -> { - // Open LICENSE file or GitHub link + // Open License on GitHub Pages Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setData(Uri.parse("https://github.com/izzy2lost/PSX2/blob/master/LICENSE")); + intent.setData(Uri.parse("https://izzy2lost.github.io/PSX2/license.html")); try { startActivity(intent); } catch (Exception e) { @@ -2818,5 +2770,4 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF } } - } diff --git a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java index f85e696..611da6d 100644 --- a/app/src/main/java/com/izzy2lost/psx2/NativeApp.java +++ b/app/src/main/java/com/izzy2lost/psx2/NativeApp.java @@ -168,6 +168,64 @@ public class NativeApp { public static native void achievementsShutdown(); public static native Achievement[] achievementsGetAchievementList(); public static native void achievementsSetHardcoreMode(boolean enabled); + public static native void achievementsLoginWithToken(String username, String token); + + // Save achievements credentials to SharedPreferences (called from native code) + public static void saveAchievementsCredentials(String username, String token, String loginTimestamp) { + Context context = getContext(); + if (context == null) { + android.util.Log.e("Achievements", "Cannot save credentials: context is null"); + return; + } + + android.content.SharedPreferences prefs = context.getSharedPreferences("RetroAchievements", Context.MODE_PRIVATE); + android.content.SharedPreferences.Editor editor = prefs.edit(); + editor.putString("username", username); + editor.putString("token", token); + editor.putString("login_timestamp", loginTimestamp); + editor.apply(); + + android.util.Log.i("Achievements", "Credentials saved: username=" + username + ", has_token=" + (!token.isEmpty())); + } + + // Load achievements credentials from SharedPreferences and attempt auto-login + public static void loadAndLoginAchievements() { + Context context = getContext(); + if (context == null) { + android.util.Log.e("Achievements", "Cannot load credentials: context is null"); + return; + } + + android.content.SharedPreferences prefs = context.getSharedPreferences("RetroAchievements", Context.MODE_PRIVATE); + boolean enabled = prefs.getBoolean("enabled", false); + + if (!enabled) { + android.util.Log.d("Achievements", "Achievements not enabled, skipping auto-login"); + return; + } + + String username = prefs.getString("username", ""); + String token = prefs.getString("token", ""); + + if (username.isEmpty() || token.isEmpty()) { + android.util.Log.d("Achievements", "No saved credentials found"); + return; + } + + android.util.Log.i("Achievements", "Attempting auto-login with saved token for user: " + username); + + // Initialize achievements system first + new Thread(() -> { + try { + achievementsInitialize(); + Thread.sleep(500); // Give it time to initialize + achievementsLoginWithToken(username, token); + android.util.Log.i("Achievements", "Auto-login initiated"); + } catch (Exception e) { + android.util.Log.e("Achievements", "Auto-login failed: " + e.getMessage()); + } + }).start(); + } public static native void onNativeSurfaceCreated(); public static native void onNativeSurfaceChanged(Surface surface, int w, int h); diff --git a/app/src/main/res/drawable/refresh_24px.xml b/app/src/main/res/drawable/refresh_24px.xml new file mode 100644 index 0000000..5170f48 --- /dev/null +++ b/app/src/main/res/drawable/refresh_24px.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout-land/dialog_covers_grid.xml b/app/src/main/res/layout-land/dialog_covers_grid.xml index c6399ab..aa1bc77 100644 --- a/app/src/main/res/layout-land/dialog_covers_grid.xml +++ b/app/src/main/res/layout-land/dialog_covers_grid.xml @@ -31,6 +31,16 @@ android:padding="4dp" android:src="@drawable/menu_24px" app:tint="@color/brand_primary" /> + + + + + + + app:iconSize="24dp" /> - + android:layout_weight="1"> + + + + - + + PSX2 - PlayStation 2 Emulator
-

PSX2

+

PSX2 RETRO

PlayStation 2 Emulator for Mobile

-

PSX2 is a powerful PlayStation 2 emulator that allows you to play your favorite PS2 games on mobile devices. Experience classic gaming with enhanced performance and modern convenience.

+

PSX2 is a powerful PlayStation 2 emulator that brings your favorite PS2 games to mobile devices. Experience classic gaming with enhanced performance, modern convenience, and authentic retro vibes.

+

Built on the legendary PCSX2 emulator and optimized for ARM64 Android devices, PSX2 delivers high compatibility and smooth gameplay.

+
+ +
+

Key Features

+
    +
  • High Compatibility - Play thousands of PS2 games
  • +
  • Enhanced Graphics - Upscaling, texture filtering, and modern rendering
  • +
  • Multiple Formats - ISO, CHD, CSO, ZSO, and compressed formats
  • +
  • Touch Controls - Intuitive on-screen gamepad with customizable layout
  • +
  • External Controllers - Full support for Bluetooth and USB gamepads
  • +
  • Save States - Quick save and load functionality
  • +
  • Game Covers - Automatic cover art downloading and display
  • +
  • Per-Game Settings - Individual configuration for optimal performance
  • +
diff --git a/docs/license.html b/docs/license.html new file mode 100644 index 0000000..1afdbb6 --- /dev/null +++ b/docs/license.html @@ -0,0 +1,210 @@ + + + + + + PSX2 - License + + + +
+ ← Back to Home + +
+

PSX2 LICENSE

+

GNU General Public License v3.0

+
+ +
+

GNU GENERAL PUBLIC LICENSE

+

Version 3, 29 June 2007

+

Copyright (C) 2007 Free Software Foundation, Inc. https://fsf.org/

+

Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.

+ +

Preamble

+

The GNU General Public License is a free, copyleft license for software and other kinds of works.

+

The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.

+

When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.

+

To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.

+

For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.

+

Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.

+

For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.

+

Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.

+

Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.

+

The precise terms and conditions for copying, distribution and modification follow.

+ +

TERMS AND CONDITIONS

+ +

0. Definitions.

+

"This License" refers to version 3 of the GNU General Public License.

+

"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.

+

"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.

+

To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.

+

A "covered work" means either the unmodified Program or a work based on the Program.

+

To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.

+

To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.

+ +

For the full license text, please visit: https://www.gnu.org/licenses/gpl-3.0.html

+
+ + +
+ +