diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java index 7a7573c5c2..a7dea1b2bf 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java @@ -279,22 +279,83 @@ private static class ModsInstallApi extends CommonApi { /** * Override getModDetails to filter versions by the selected MC version. * Only versions matching the filter are shown in the version dropdown. + * Also resolves which (if any) of the returned versions is already + * installed in the current instance's mods folder, so the install + * button can show Install / Installed / Update / Downgrade. */ @Override public ModDetail getModDetails(ModItem item) { + ModDetail detail; if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH) { String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) ? mFilters.mcVersion : null; String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty()) ? mFilters.modLoader : null; - return mModrinthApi.getModDetails(item, filterVer, filterLoader); - } - if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_CURSEFORGE) { + detail = mModrinthApi.getModDetails(item, filterVer, filterLoader); + } else if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_CURSEFORGE) { String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty()) ? mFilters.mcVersion : null; - return mCurseforgeApi.getModDetails(item, filterVer); + detail = mCurseforgeApi.getModDetails(item, filterVer); + } else { + detail = super.getModDetails(item); + } + + if (detail != null && !detail.isModpack) { + resolveInstalledVersionIndex(detail); + } + return detail; + } + + /** + * Hashes every jar currently in the mods folder (enabled or disabled) + * and matches against detail.versionHashes to find which version, if + * any, is already installed. Sets detail.installedVersionIndex + * accordingly (-1 if this mod isn't installed at all). + */ + private void resolveInstalledVersionIndex(ModDetail detail) { + detail.installedVersionIndex = -1; + detail.installedFilePath = null; + if (detail.versionHashes == null || detail.versionHashes.length == 0) return; + + File modsDir = getModsDir(); + File[] files = modsDir.listFiles(f -> f.isFile() && + (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled"))); + if (files == null || files.length == 0) return; + + java.util.Map installedHashToFile = new java.util.HashMap<>(); + for (File f : files) { + String hash = sha1Hex(f); + if (hash != null) installedHashToFile.put(hash, f); + } + if (installedHashToFile.isEmpty()) return; + + for (int i = 0; i < detail.versionHashes.length; i++) { + String hash = detail.versionHashes[i]; + if (hash == null) continue; + File match = installedHashToFile.get(hash.toLowerCase(java.util.Locale.ROOT)); + if (match != null) { + detail.installedVersionIndex = i; + detail.installedFilePath = match.getAbsolutePath(); + break; + } + } + } + + private static String sha1Hex(File file) { + try { + java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-1"); + byte[] buffer = new byte[8192]; + try (java.io.FileInputStream fis = new java.io.FileInputStream(file)) { + int read; + while ((read = fis.read(buffer)) != -1) digest.update(buffer, 0, read); + } + byte[] bytes = digest.digest(); + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) sb.append(String.format("%02x", b)); + return sb.toString(); + } catch (Exception e) { + return null; } - return super.getModDetails(item); } @Override @@ -338,13 +399,19 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte if (rawName.contains("?")) rawName = rawName.substring(0, rawName.indexOf('?')); final String fileName = rawName.endsWith(".jar") ? rawName : rawName + ".jar"; + // If a different version of this same mod is already installed under + // a different file name (the Update/Downgrade case), remember its path + // so downloadMod() can remove it once the new file is safely down — + // otherwise both versions end up sitting in the mods folder together. + final String oldFilePath = modDetail.installedFilePath; + // Check if this version has dependencies String[] depIds = (modDetail.versionDependencyIds != null) ? modDetail.versionDependencyIds[selectedVersion] : null; String[] depTypes = (modDetail.versionDependencyTypes != null) ? modDetail.versionDependencyTypes[selectedVersion] : null; if (depIds == null || depIds.length == 0) { // No deps — download directly - downloadMod(context, url, fileName, new String[0], new String[0]); + downloadMod(context, url, fileName, new String[0], new String[0], oldFilePath); return; } @@ -372,7 +439,7 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte labels[idx] = prefix + (name != null ? name : projectId); if (remaining.decrementAndGet() == 0) { mMainHandler.post(() -> showDepsDialog(context, url, fileName, - depIds, depTypes, labels, checkedDefaults)); + depIds, depTypes, labels, checkedDefaults, oldFilePath)); } }); } @@ -380,7 +447,7 @@ public void handleInstallation(Context context, ModDetail modDetail, int selecte private void showDepsDialog(Context context, String url, String fileName, String[] depIds, String[] depTypes, - String[] labels, boolean[] checkedDefaults) { + String[] labels, boolean[] checkedDefaults, String oldFilePath) { // context here is getApplicationContext() from ModItemAdapter — no window token. // Use the stored Activity reference instead. Context dialogCtx = mActivityContext != null ? mActivityContext : context; @@ -396,24 +463,37 @@ private void showDepsDialog(Context context, String url, String fileName, if (selected[i]) selectedIds.add(depIds[i]); } downloadMod(context, url, fileName, - selectedIds.toArray(new String[0]), depTypes); + selectedIds.toArray(new String[0]), depTypes, oldFilePath); }) .setNeutralButton(R.string.mod_deps_install_without, - (d, w) -> downloadMod(context, url, fileName, new String[0], new String[0])) + (d, w) -> downloadMod(context, url, fileName, new String[0], new String[0], oldFilePath)) .setNegativeButton(android.R.string.cancel, null) .show(); } private void downloadMod(Context context, String url, String fileName, - String[] depIds, String[] depTypes) { + String[] depIds, String[] depTypes, @Nullable String oldFilePath) { File modsDir = getModsDir(); if (!modsDir.exists()) modsDir.mkdirs(); + final File targetFile = new File(modsDir, fileName); + ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting); PojavApplication.sExecutorService.execute(() -> { try { // Download main mod - DownloadUtils.downloadFile(url, new File(modsDir, fileName)); + DownloadUtils.downloadFile(url, targetFile); + + // This is an update/downgrade of an already-installed mod under a + // different file name — remove the old jar now that the new one + // downloaded successfully, so both versions don't end up sitting + // in the mods folder side-by-side. + if (oldFilePath != null) { + File oldFile = new File(oldFilePath); + if (oldFile.exists() && !oldFile.getAbsolutePath().equals(targetFile.getAbsolutePath())) { + oldFile.delete(); + } + } // Download selected dependencies for (String depId : depIds) { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java index 887be5716b..a08a71092b 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java @@ -1,5 +1,6 @@ package net.kdt.pojavlaunch.modloaders; +import android.annotation.SuppressLint; import android.app.AlertDialog; import android.content.Context; import android.graphics.Bitmap; @@ -10,14 +11,17 @@ import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.Button; import android.widget.ImageButton; import android.widget.ImageView; +import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.SwitchCompat; +import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.gson.JsonArray; @@ -319,6 +323,387 @@ private void applyUpdate(Context context, ModEntry entry, int position) { }); } + // ── Switch version ─────────────────────────────────────────────────────── + + /** + * Opens a Modrinth-style "Switch version" dialog for the given mod: resolves + * its Modrinth project id from the installed jar's SHA1 hash, fetches every + * version of that project (not just ones matching the instance's mc + * version/loader, so the user can also see — and optionally show — + * incompatible versions), and lets them tap any version to switch to it. + */ + private void showSwitchVersionDialog(Context context, ModEntry entry, int position) { + View dialogView = LayoutInflater.from(context).inflate(R.layout.dialog_switch_mod_version, null); + AlertDialog dialog = new AlertDialog.Builder(context) + .setView(dialogView) + .create(); + + TextView titleView = dialogView.findViewById(R.id.switch_version_title); + ImageButton closeButton = dialogView.findViewById(R.id.switch_version_close); + ProgressBar progressBar = dialogView.findViewById(R.id.switch_version_progress); + View errorLayout = dialogView.findViewById(R.id.switch_version_error_layout); + TextView errorTextView = dialogView.findViewById(R.id.switch_version_error_textview); + Button retryButton = dialogView.findViewById(R.id.switch_version_retry_button); + RecyclerView listView = dialogView.findViewById(R.id.switch_version_list); + TextView toggleIncompatible = dialogView.findViewById(R.id.switch_version_toggle_incompatible); + titleView.setText(context.getString(R.string.switch_mod_version_title) + " — " + entry.displayName()); + closeButton.setOnClickListener(v -> dialog.dismiss()); + + listView.setLayoutManager(new LinearLayoutManager(context)); + VersionRowAdapter adapter = new VersionRowAdapter(chosen -> { + String message = chosen.isCurrent + ? context.getString(R.string.switch_mod_version_confirm_reinstall_message, entry.displayName()) + : context.getString(R.string.switch_mod_version_confirm_message, entry.displayName(), chosen.versionNumber); + new AlertDialog.Builder(context) + .setTitle(R.string.switch_mod_version_confirm_title) + .setMessage(message) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (d, w) -> { + dialog.dismiss(); + applySwitchVersion(context, entry, position, chosen.fileUrl, chosen.fileName, chosen.versionNumber); + }) + .show(); + }); + listView.setAdapter(adapter); + + final boolean[] showIncompatible = {false}; + toggleIncompatible.setOnClickListener(v -> { + showIncompatible[0] = !showIncompatible[0]; + toggleIncompatible.setText(showIncompatible[0] + ? R.string.switch_mod_version_hide_incompatible + : R.string.switch_mod_version_show_incompatible); + adapter.setShowIncompatible(showIncompatible[0]); + }); + + Runnable loadVersions = () -> { + progressBar.setVisibility(View.VISIBLE); + errorLayout.setVisibility(View.GONE); + listView.setVisibility(View.GONE); + + sUpdateCheckExecutor.execute(() -> { + List rows = null; + boolean projectNotFound = false; + try { + String sha1 = sha1Hex(entry.file); + Log.d(TAG, "Switch-version: hashing " + entry.file.getName() + " -> " + sha1); + ApiHandler api = new ApiHandler(MODRINTH_API); + String projectId = null; + String currentVersionId = null; + + if (sha1 != null) { + java.util.HashMap hashParams = new java.util.HashMap<>(); + hashParams.put("algorithm", "sha1"); + JsonObject fileVersion = api.get("version_file/" + sha1, hashParams, JsonObject.class); + if (fileVersion != null) { + if (fileVersion.has("project_id")) projectId = fileVersion.get("project_id").getAsString(); + if (fileVersion.has("id")) currentVersionId = fileVersion.get("id").getAsString(); + } + Log.d(TAG, "Switch-version: version_file lookup -> projectId=" + projectId + + " currentVersionId=" + currentVersionId); + } + + if (projectId == null) { + projectNotFound = true; + Log.w(TAG, "Switch-version: " + entry.displayName() + + " — no Modrinth project found for this jar's hash"); + } else { + JsonArray versions = api.get("project/" + projectId + "/version", JsonArray.class); + Log.d(TAG, "Switch-version: project/" + projectId + "/version returned " + + (versions == null ? "null" : versions.size() + " entries")); + if (versions != null && versions.size() > 0) { + rows = new ArrayList<>(); + for (int i = 0; i < versions.size(); i++) { + VersionRow row = parseVersionRow(versions.get(i).getAsJsonObject(), currentVersionId); + if (row != null) rows.add(row); + } + Log.d(TAG, "Switch-version: parsed " + rows.size() + "/" + versions.size() + + " versions for " + entry.displayName()); + } + } + } catch (Exception e) { + Log.w(TAG, "Switch-version fetch failed for " + entry.displayName(), e); + } + + final List finalRows = rows; + final boolean finalNotFound = projectNotFound; + mMainHandler.post(() -> { + progressBar.setVisibility(View.GONE); + if (finalRows == null || finalRows.isEmpty()) { + errorLayout.setVisibility(View.VISIBLE); + listView.setVisibility(View.GONE); + errorTextView.setText(finalNotFound + ? R.string.switch_mod_version_not_found + : R.string.switch_mod_version_no_versions); + } else { + errorLayout.setVisibility(View.GONE); + listView.setVisibility(View.VISIBLE); + adapter.setVersions(finalRows); + } + }); + }); + }; + + retryButton.setOnClickListener(v -> loadVersions.run()); + loadVersions.run(); + dialog.show(); + } + + /** Parses one entry of Modrinth's GET /project/{id}/version response. */ + @Nullable + private VersionRow parseVersionRow(JsonObject v, @Nullable String currentVersionId) { + try { + if (!v.has("id") || v.get("id").isJsonNull()) return null; + String id = v.get("id").getAsString(); + + JsonArray files = v.has("files") ? v.getAsJsonArray("files") : null; + JsonObject primaryFile = null; + if (files != null) { + for (int i = 0; i < files.size(); i++) { + JsonObject f = files.get(i).getAsJsonObject(); + if (f.has("primary") && f.get("primary").getAsBoolean()) { primaryFile = f; break; } + } + if (primaryFile == null && files.size() > 0) primaryFile = files.get(0).getAsJsonObject(); + } + if (primaryFile == null || !primaryFile.has("url")) return null; + + String fileUrl = primaryFile.get("url").getAsString(); + String fileName = fileUrl.substring(fileUrl.lastIndexOf('/') + 1); + if (fileName.contains("?")) fileName = fileName.substring(0, fileName.indexOf('?')); + + VersionRow row = new VersionRow(); + row.id = id; + row.versionNumber = v.has("version_number") && !v.get("version_number").isJsonNull() + ? v.get("version_number").getAsString() : id; + row.releaseType = v.has("version_type") && !v.get("version_type").isJsonNull() + ? v.get("version_type").getAsString() : "release"; + row.datePublished = v.has("date_published") && !v.get("date_published").isJsonNull() + ? v.get("date_published").getAsString() : null; + row.fileUrl = fileUrl; + row.fileName = fileName; + + row.gameVersions = new ArrayList<>(); + if (v.has("game_versions")) { + JsonArray arr = v.getAsJsonArray("game_versions"); + for (int i = 0; i < arr.size(); i++) row.gameVersions.add(arr.get(i).getAsString()); + } + row.loaders = new ArrayList<>(); + if (v.has("loaders")) { + JsonArray arr = v.getAsJsonArray("loaders"); + for (int i = 0; i < arr.size(); i++) row.loaders.add(arr.get(i).getAsString()); + } + + row.isCurrent = id.equals(currentVersionId); + boolean mcOk = mFilterMcVersion.isEmpty() || row.gameVersions.contains(mFilterMcVersion); + boolean loaderOk = mFilterLoader.isEmpty() || containsIgnoreCase(row.loaders, mFilterLoader); + row.isCompatible = mcOk && loaderOk; + return row; + } catch (Exception e) { + return null; + } + } + + private static boolean containsIgnoreCase(List list, String value) { + for (String s : list) if (s.equalsIgnoreCase(value)) return true; + return false; + } + + /** Downloads the chosen version and swaps it in, mirroring applyUpdate(). */ + private void applySwitchVersion(Context context, ModEntry entry, int position, + String url, String fileName, String versionLabel) { + boolean wasDisabled = entry.file.getName().endsWith(".disabled"); + String targetName = wasDisabled ? fileName + ".disabled" : fileName; + File targetFile = new File(entry.file.getParent(), targetName); + final File oldFile = entry.file; + + Toast.makeText(context, + context.getString(R.string.switch_mod_version_switching, versionLabel), + Toast.LENGTH_SHORT).show(); + + sUpdateCheckExecutor.execute(() -> { + try { + File tmpFile = new File(entry.file.getParent(), targetName + ".tmp"); + DownloadUtils.downloadFile(url, tmpFile); + + oldFile.delete(); + tmpFile.renameTo(targetFile); + + mMainHandler.post(() -> { + entry.file = targetFile; + entry.enabled = !wasDisabled; + entry.updateUrl = null; + entry.updateFileName = null; + + // The file path changed — drop cache entries keyed by the old + // path so the icon/name get freshly re-resolved for the new jar. + String oldPath = oldFile.getAbsolutePath(); + mIconCache.remove(oldPath); + mModNameCache.remove(oldPath); + mIconCheckedNoResult.remove(oldPath); + mIconResolving.remove(oldPath); + mModNameResolving.remove(oldPath); + + if (position < mMods.size()) notifyItemChanged(position); + Toast.makeText(context, + context.getString(R.string.switch_mod_version_done, entry.displayName(), versionLabel), + Toast.LENGTH_SHORT).show(); + }); + } catch (Exception e) { + Log.e(TAG, "Version switch failed: " + e.getMessage()); + mMainHandler.post(() -> + Toast.makeText(context, + context.getString(R.string.switch_mod_version_failed, entry.displayName()), + Toast.LENGTH_SHORT).show()); + } + }); + } + + /** Lightweight model for one row in the switch-version list. */ + private static class VersionRow { + String id, versionNumber, releaseType, datePublished, fileUrl, fileName; + List gameVersions = new ArrayList<>(); + List loaders = new ArrayList<>(); + boolean isCurrent; + boolean isCompatible; + } + + /** RecyclerView adapter backing the switch-version dialog's list. */ + private static class VersionRowAdapter extends RecyclerView.Adapter { + + interface OnVersionClickListener { + void onVersionClick(VersionRow row); + } + + private final OnVersionClickListener mListener; + private List mAllRows = new ArrayList<>(); + private List mVisibleRows = new ArrayList<>(); + private boolean mShowIncompatible = false; + + VersionRowAdapter(OnVersionClickListener listener) { + mListener = listener; + } + + @SuppressLint("NotifyDataSetChanged") + void setVersions(List rows) { + mAllRows = rows; + applyFilter(); + } + + @SuppressLint("NotifyDataSetChanged") + void setShowIncompatible(boolean show) { + mShowIncompatible = show; + applyFilter(); + } + + @SuppressLint("NotifyDataSetChanged") + private void applyFilter() { + List visible = new ArrayList<>(); + for (VersionRow row : mAllRows) { + // Always keep the currently installed version visible, even if it + // no longer matches the instance's filter (e.g. mc version changed). + if (!mShowIncompatible && !row.isCompatible && !row.isCurrent) continue; + visible.add(row); + } + // Defensive fallback: if the compatibility filter would leave nothing + // on screen despite having actually fetched versions (e.g. the + // instance's mc version/loader didn't match Modrinth's strings for + // any of them, and the currently installed version couldn't be + // identified either), fall back to showing everything rather than + // silently rendering an empty list with no obvious explanation. + mVisibleRows = visible.isEmpty() ? mAllRows : visible; + notifyDataSetChanged(); + } + + @NonNull + @Override + public RowHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_mod_version_row, parent, false); + return new RowHolder(v); + } + + @Override + public void onBindViewHolder(@NonNull RowHolder holder, int position) { + holder.bind(mVisibleRows.get(position), mListener); + } + + @Override + public int getItemCount() { + return mVisibleRows.size(); + } + + static class RowHolder extends RecyclerView.ViewHolder { + final TextView nameView, subtitleView, currentLabel; + final ImageView currentBadge, incompatibleIcon; + + RowHolder(@NonNull View itemView) { + super(itemView); + nameView = itemView.findViewById(R.id.version_row_name); + subtitleView = itemView.findViewById(R.id.version_row_subtitle); + currentLabel = itemView.findViewById(R.id.version_row_current_pill); + currentBadge = itemView.findViewById(R.id.version_row_current_badge); + incompatibleIcon = itemView.findViewById(R.id.version_row_incompatible_icon); + } + + void bind(VersionRow row, OnVersionClickListener listener) { + nameView.setText(row.versionNumber); + subtitleView.setText(buildSubtitle(row)); + + if (row.isCurrent) { + currentLabel.setVisibility(View.VISIBLE); + currentBadge.setVisibility(View.VISIBLE); + incompatibleIcon.setVisibility(View.GONE); + } else { + currentLabel.setVisibility(View.GONE); + currentBadge.setVisibility(View.GONE); + incompatibleIcon.setVisibility(row.isCompatible ? View.GONE : View.VISIBLE); + } + + itemView.setOnClickListener(v -> listener.onVersionClick(row)); + } + + private static String buildSubtitle(VersionRow row) { + StringBuilder sb = new StringBuilder(); + // Release type as a plain word (Release / Beta / Alpha) + if (row.releaseType != null && !row.releaseType.isEmpty()) { + sb.append(capitalize(row.releaseType)); + } + if (!row.loaders.isEmpty()) { + if (sb.length() > 0) sb.append(" • "); + sb.append(capitalize(row.loaders.get(0))); + } + if (!row.gameVersions.isEmpty()) { + if (sb.length() > 0) sb.append(' '); + sb.append(row.gameVersions.get(row.gameVersions.size() - 1)); + } + String date = formatDate(row.datePublished); + if (date != null) { + if (sb.length() > 0) sb.append(" • "); + sb.append(date); + } + return sb.toString(); + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return s.substring(0, 1).toUpperCase(java.util.Locale.ROOT) + s.substring(1); + } + + @Nullable + private static String formatDate(@Nullable String iso) { + if (iso == null || iso.length() < 10) return iso; + try { + String datePart = iso.substring(0, 10); + java.text.SimpleDateFormat in = new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.US); + java.util.Date date = in.parse(datePart); + java.text.SimpleDateFormat out = new java.text.SimpleDateFormat("MMM d, yyyy", java.util.Locale.getDefault()); + return date != null ? out.format(date) : datePart; + } catch (Exception e) { + return iso.substring(0, 10); + } + } + } + } + // ── Adapter ─────────────────────────────────────────────────────────── @NonNull @@ -357,6 +742,7 @@ class ModViewHolder extends RecyclerView.ViewHolder { final SwitchCompat toggle; final android.widget.Button update; final ImageButton delete; + final ImageButton switchVersion; ModViewHolder(@NonNull View itemView) { super(itemView); @@ -366,6 +752,7 @@ class ModViewHolder extends RecyclerView.ViewHolder { toggle = itemView.findViewById(R.id.installed_mod_toggle); update = itemView.findViewById(R.id.installed_mod_update); delete = itemView.findViewById(R.id.installed_mod_delete); + switchVersion = itemView.findViewById(R.id.installed_mod_switch_version); } void bind(ModEntry entry) { @@ -428,6 +815,11 @@ void bind(ModEntry entry) { update.setOnClickListener(null); } + switchVersion.setOnClickListener(v -> { + int pos = getBindingAdapterPosition(); + if (pos != RecyclerView.NO_POSITION) showSwitchVersionDialog(v.getContext(), entry, pos); + }); + delete.setOnClickListener(v -> { Context ctx = v.getContext(); new AlertDialog.Builder(ctx) diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java index 5eab19ab04..1edf60e8bd 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/ModItemAdapter.java @@ -7,12 +7,14 @@ import android.view.View; import android.view.ViewGroup; import android.view.ViewStub; +import android.widget.AdapterView; import android.widget.Button; import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.graphics.drawable.RoundedBitmapDrawable; import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; @@ -175,10 +177,37 @@ public ViewHolder(View view) { mExtendedSpinner = mExtendedLayout.findViewById(R.id.mod_extended_version_spinner); mExtendedErrorTextView = mExtendedLayout.findViewById(R.id.mod_extended_error_textview); - mExtendedButton.setOnClickListener(v1 -> mModpackApi.handleInstallation( - mExtendedButton.getContext().getApplicationContext(), - mModDetail, - mExtendedSpinner.getSelectedItemPosition())); + mExtendedButton.setOnClickListener(v1 -> { + final int selectedVersion = mExtendedSpinner.getSelectedItemPosition(); + if (computeButtonState(selectedVersion) == InstallButtonState.INSTALLED) { + new AlertDialog.Builder(mExtendedButton.getContext()) + .setTitle(R.string.mod_reinstall_confirm_title) + .setMessage(mExtendedButton.getContext().getString( + R.string.mod_reinstall_confirm_message, + mModDetail != null ? mModDetail.title : "")) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, (d, w) -> + mModpackApi.handleInstallation( + mExtendedButton.getContext().getApplicationContext(), + mModDetail, + selectedVersion)) + .show(); + } else { + mModpackApi.handleInstallation( + mExtendedButton.getContext().getApplicationContext(), + mModDetail, + selectedVersion); + } + }); + mExtendedSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { + @Override + public void onItemSelected(AdapterView parent, View selectedView, int position, long id) { + updateExtendedButtonLabel(); + } + + @Override + public void onNothingSelected(AdapterView parent) { } + }); mExtendedSpinner.setAdapter(mLoadingAdapter); } else { if(isExtended()) closeDetailedView(); @@ -268,17 +297,20 @@ public void setStateLimited(ModItem item) { /** Display extended info/interaction about a modpack */ private void setStateDetailed(ModDetail detailedItem) { + mModDetail = detailedItem; if(detailedItem != null) { setInstallEnabled(true); mExtendedErrorTextView.setVisibility(View.GONE); mVersionAdapter.setObjects(Arrays.asList(detailedItem.versionNames)); mExtendedSpinner.setAdapter(mVersionAdapter); + updateExtendedButtonLabel(); } else { closeDetailedView(); setInstallEnabled(false); mExtendedErrorTextView.setVisibility(View.VISIBLE); mExtendedSpinner.setAdapter(null); mVersionAdapter.setObjects(null); + if (mExtendedButton != null) mExtendedButton.setText(R.string.generic_install); } } @@ -333,8 +365,52 @@ private void updateInstallButtonState() { if(mExtendedButton != null) mExtendedButton.setEnabled(mInstallEnabled && !mTasksRunning); } + + /** + * Compares a candidate version index against mModDetail.installedVersionIndex + * (resolved by ModsInstallApi via local jar hashing) to decide whether the + * install button should read Install / Installed / Update / Downgrade. + * Versions are ordered newest-first, so a lower index than the installed + * one means "this is newer" (Update), and a higher index means "this is + * older" (Downgrade). + */ + private InstallButtonState computeButtonState(int selectedIndex) { + if (mModDetail == null || mModDetail.isModpack || mModDetail.installedVersionIndex < 0) { + return InstallButtonState.INSTALL; + } + int installedIndex = mModDetail.installedVersionIndex; + if (selectedIndex == installedIndex) return InstallButtonState.INSTALLED; + return selectedIndex < installedIndex ? InstallButtonState.UPDATE : InstallButtonState.DOWNGRADE; + } + + private void updateExtendedButtonLabel() { + if (mExtendedButton == null || mExtendedSpinner == null) return; + int selectedIndex = mExtendedSpinner.getSelectedItemPosition(); + switch (computeButtonState(selectedIndex)) { + case INSTALLED: + mExtendedButton.setText(R.string.mod_install_installed); + break; + case UPDATE: + mExtendedButton.setText(R.string.mod_install_update); + break; + case DOWNGRADE: + mExtendedButton.setText(R.string.mod_install_downgrade); + break; + default: + mExtendedButton.setText(R.string.generic_install); + break; + } + } } + /** + * Install button states for the search/install screen — moved out of the + * (non-static) ViewHolder inner class since Java forbids static + * declarations, which an enum implicitly is, inside non-static inner + * classes. + */ + private enum InstallButtonState { INSTALL, INSTALLED, UPDATE, DOWNGRADE } + /** * The view holder used to hold the progress bar at the end of the list */ @@ -406,4 +482,4 @@ public interface SearchResultCallback { void onSearchFinished(); void onSearchError(int error); } -} +} \ No newline at end of file diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java index d080a2669a..cf8df7eca8 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/modpacks/models/ModDetail.java @@ -16,6 +16,20 @@ public class ModDetail extends ModItem { public String[][] versionDependencyIds; /* Per-version dependency types — "required" or "optional" */ public String[][] versionDependencyTypes; + /* Index into the version arrays that matches a mod already present in the + * current instance's mods folder (matched by SHA1 hash), or -1 if this + * mod/project isn't currently installed. Resolved by the caller after + * fetching the details, since hashing local files needs filesystem access + * this model class doesn't have. Versions are ordered newest-first, so a + * lower index than installedVersionIndex means "update available", and a + * higher index means "downgrade". */ + public int installedVersionIndex = -1; + /* Absolute path of the on-disk jar that matched installedVersionIndex + * (set alongside it by the same resolution pass), or null if nothing is + * installed. Used by the install/update/downgrade flow to remove the old + * file once the new version has finished downloading, instead of leaving + * both versions sitting in the mods folder side-by-side. */ + public String installedFilePath = null; public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls, String[] hashes) { this(item, versionNames, mcVersionNames, versionUrls, hashes, null, null); diff --git a/app_pojavlauncher/src/main/res/drawable/bg_circle_badge.xml b/app_pojavlauncher/src/main/res/drawable/bg_circle_badge.xml new file mode 100644 index 0000000000..dcceb84a5b --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/bg_circle_badge.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/bg_version_current_pill.xml b/app_pojavlauncher/src/main/res/drawable/bg_version_current_pill.xml new file mode 100644 index 0000000000..c93aebb931 --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/bg_version_current_pill.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/bg_version_row.xml b/app_pojavlauncher/src/main/res/drawable/bg_version_row.xml new file mode 100644 index 0000000000..2030b0656f --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/bg_version_row.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/bg_version_row_current.xml b/app_pojavlauncher/src/main/res/drawable/bg_version_row_current.xml new file mode 100644 index 0000000000..ed67adfc1e --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/bg_version_row_current.xml @@ -0,0 +1,9 @@ + + + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/ic_check_circle.xml b/app_pojavlauncher/src/main/res/drawable/ic_check_circle.xml index 8e69cf4f0e..b605a126d3 100644 --- a/app_pojavlauncher/src/main/res/drawable/ic_check_circle.xml +++ b/app_pojavlauncher/src/main/res/drawable/ic_check_circle.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - - + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/ic_close.xml b/app_pojavlauncher/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000000..8349d981cc --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/ic_incompatible_warning.xml b/app_pojavlauncher/src/main/res/drawable/ic_incompatible_warning.xml new file mode 100644 index 0000000000..6b036fcfb0 --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/ic_incompatible_warning.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/drawable/ic_swap_horiz.xml b/app_pojavlauncher/src/main/res/drawable/ic_swap_horiz.xml new file mode 100644 index 0000000000..8fa99a8149 --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/ic_swap_horiz.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/layout/dialog_switch_mod_version.xml b/app_pojavlauncher/src/main/res/layout/dialog_switch_mod_version.xml new file mode 100644 index 0000000000..b14c2798d3 --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/dialog_switch_mod_version.xml @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml b/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml index 550783e49c..b67071f9a7 100644 --- a/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml +++ b/app_pojavlauncher/src/main/res/layout/item_installed_mod.xml @@ -85,6 +85,19 @@ android:paddingHorizontal="@dimen/padding_moderate" android:paddingVertical="@dimen/padding_small" android:visibility="gone" + app:layout_constraintEnd_toStartOf="@+id/installed_mod_switch_version" + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintBottom_toBottomOf="parent" /> + + + diff --git a/app_pojavlauncher/src/main/res/layout/item_mod_version_row.xml b/app_pojavlauncher/src/main/res/layout/item_mod_version_row.xml new file mode 100644 index 0000000000..9070f54207 --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/item_mod_version_row.xml @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/values/colors.xml b/app_pojavlauncher/src/main/res/values/colors.xml index 53b403bf6f..b63e7e676d 100644 --- a/app_pojavlauncher/src/main/res/values/colors.xml +++ b/app_pojavlauncher/src/main/res/values/colors.xml @@ -71,4 +71,11 @@ #E0F7FA #80C8D4 + + #1BD96A + #F5C722 + #F3565D + #F7A325 + #1BD96A + \ No newline at end of file diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml index 9233084270..03ef27f22f 100644 --- a/app_pojavlauncher/src/main/res/values/strings.xml +++ b/app_pojavlauncher/src/main/res/values/strings.xml @@ -111,6 +111,32 @@ Filter cleared %s installed to mods folder. Search for mods + + + Installed + Update + Downgrade + Re-install mod? + %s is already installed at this version. Re-install it anyway? + + + Switch version + Switch version + Current + Show incompatible + Hide incompatible + Switching versions can break your instance. Review version changelogs and back up first. + Switch to %s + Loading versions… + No versions found for this mod. + Couldn\'t find this mod on Modrinth — it may have been installed from another source. + Retry + Switch version? + Switch %1$s to version %2$s? This will replace the currently installed jar. + This is the currently installed version of %1$s. Re-install it anyway? + Switching %s… + %1$s switched to %2$s + Failed to switch version for %s Default Off