diff --git a/CRAFTYN_MC_WEBSITE_CHANGES.txt b/CRAFTYN_MC_WEBSITE_CHANGES.txt new file mode 100644 index 0000000000..5e3878f6c8 --- /dev/null +++ b/CRAFTYN_MC_WEBSITE_CHANGES.txt @@ -0,0 +1,43 @@ +# CraftynMC Website Integration Tutorial (Express Backend Changes) +================================================================== + +To support the live launcher-side username availability check, you should add a lightweight public endpoint to your Express/Node.js backend. This endpoint will be queried in real-time by the launcher as the player types their username. + +### Step 1: Open your backend server code (e.g., `server.js` or `app.js`) + +Locate where your routes are registered (typically near your `/login` or `/register` route definitions). + +### Step 2: Paste the following `/api/check-username` endpoint code: + +```javascript +// Live Username Availability Check Endpoint +app.get('/api/check-username', async (req, res) => { + try { + const username = req.query.username; + if (!username) { + return res.status(400).json({ error: "Username parameter is required." }); + } + + // Search your database for an existing user with the same username (case-insensitive) + // Assuming you are using MongoDB/Mongoose: + const existingUser = await User.findOne({ + username: { $regex: new RegExp("^" + username + "$", "i") } + }); + + if (existingUser) { + // Username is already registered and taken + return res.json({ available: false, message: "Username is already taken." }); + } else { + // Username is free and available to register + return res.json({ available: true, message: "Username is available!" }); + } + } catch (error) { + console.error("Error in check-username:", error); + res.status(500).json({ error: "Internal server error." }); + } +}); +``` + +### Step 3: Redeploy your backend on Render! + +Once redeployed, the launcher will dynamically query `https://farmer-my1t.onrender.com/api/check-username?username={username}` in the background and show the live green "✓ Username Available" status! diff --git a/WEBSITE_SKIN_PROXY_CHANGES.txt b/WEBSITE_SKIN_PROXY_CHANGES.txt new file mode 100644 index 0000000000..95aaeda9c8 --- /dev/null +++ b/WEBSITE_SKIN_PROXY_CHANGES.txt @@ -0,0 +1,289 @@ +# CraftynMC / FEAR Network Yggdrasil API Server Guide +===================================================== + +This guide shows you exactly how to write or update your backend code (Node.js/Express) at `https://farmer-my1t.onrender.com/` to make skins show up perfectly on MULTIPLAYER servers! + +The game client (using authlib-injector) sends all skin queries and multiplayer session verifications directly to your website. You must implement three core Yggdrasil endpoints: +1. `/sessionserver/session/minecraft/join` (Client tells server they are joining) +2. `/sessionserver/session/minecraft/hasJoined` (Server asks your API if the client actually joined) +3. `/sessionserver/session/minecraft/profile/:uuid` (Retrieves player profile/skin) + +Here is the complete, production-ready implementation of these endpoints using your website's private key for signing. + +--- + +## 1. Prerequisites (Setup RSA Keys on your Server) + +Authlib-injector requires all profile responses to be signed with the website's custom private key. +Ensure you have loaded your RSA keys in your code: + +```javascript +const fs = require('fs'); +const crypto = require('crypto'); + +// Load your keys (usually loaded at server startup) +const keys = { + publicKey: fs.readFileSync('./keys/public.pem', 'utf8'), + privateKey: fs.readFileSync('./keys/private.pem', 'utf8') +}; + +// Simple signing function +function signPayload(privateKeyPem, data) { + const sign = crypto.createSign('SHA1WithRSA'); + sign.update(data); + return sign.sign(privateKeyPem, 'base64'); +} +``` + +--- + +## 2. In-Memory Session Storage (For Multiplayer Joining) + +Minecraft multiplayer servers use a "handshake" to authenticate offline/injector players: +- The client contacts `/join` with their `accessToken`, `selectedProfile` (UUID), and a `serverId` (hash). +- Your API must temporarily store this server join request. +- The multiplayer server then contacts `/hasJoined?username=NAME&serverId=HASH` to verify the connection. + +Add a simple global map to store these active join states: + +```javascript +// Map to temporarily store active server joining sessions +const activeSessions = new Map(); // key: "username:serverId", value: { uuid, properties } +``` + +--- + +## 3. Implement the Routes in your Express App (`src/routes/yggdrasil.js`) + +Replace or add these three endpoints in your routing file: + +```javascript +const fetch = require("node-fetch"); // Or use Node 18+ global fetch + +// --------------------------------------------------------------------- +// 1. JOIN ENDPOINT (Client-side Handshake) +// --------------------------------------------------------------------- +router.post("/sessionserver/session/minecraft/join", async (req, res) => { + try { + const { accessToken, selectedProfile, serverId } = req.body; + if (!accessToken || !selectedProfile || !serverId) { + return res.status(400).json({ error: "Bad Request", errorMessage: "Missing parameters" }); + } + + // Fetch user matching the selectedProfile UUID from your MongoDB database + const user = await User.findOne({ uuid: selectedProfile }); + if (!user) { + return res.status(401).json({ error: "Forbidden", errorMessage: "Invalid profile uuid" }); + } + + // Save session details temporarily + const sessionKey = `${user.username.toLowerCase()}:${serverId}`; + activeSessions.set(sessionKey, { + uuid: selectedProfile.replace(/-/g, "").toLowerCase(), + username: user.username, + userRecord: user + }); + + // Clean up session automatically after 30 seconds to prevent memory leaks + setTimeout(() => { + activeSessions.delete(sessionKey); + }, 30000); + + console.log(`[Yggdrasil] Join registered for ${user.username} on serverId: ${serverId}`); + return res.status(204).end(); + } catch (err) { + console.error("Error in join route:", err); + return res.status(500).json({ error: "Internal Server Error" }); + } +}); + +// --------------------------------------------------------------------- +// 2. HASJOINED ENDPOINT (Server-side Verification) +// --------------------------------------------------------------------- +router.get("/sessionserver/session/minecraft/hasJoined", async (req, res) => { + try { + const { username, serverId } = req.query; + if (!username || !serverId) { + return res.status(400).end(); + } + + const sessionKey = `${username.toLowerCase()}:${serverId}`; + const session = activeSessions.get(sessionKey); + + if (session) { + // Player successfully verified! Build their signed profile response + console.log(`[Yggdrasil] Server successfully verified connection of ${username}`); + activeSessions.delete(sessionKey); // Consume session + + const compactUuid = session.uuid; + const properties = []; + + // Build skin textures property if user has a custom skin + const texturesPayload = { + timestamp: Date.now(), + profileId: compactUuid, + profileName: session.username, + textures: { + SKIN: { + url: `https://farmer-my1t.onrender.com/skins/${session.username}.png` + } + } + }; + + // Set skin model if slim/Alex + if (session.userRecord.skinModel === "slim") { + texturesPayload.textures.SKIN.metadata = { model: "slim" }; + } + + const valBase64 = Buffer.from(JSON.stringify(texturesPayload)).toString("base64"); + const signature = signPayload(keys.privateKey, valBase64); + + properties.push({ + name: "textures", + value: valBase64, + signature: signature + }); + + return res.json({ + id: compactUuid, + name: session.username, + properties: properties + }); + } + + // If not found locally, fallback to proxy verifying with Mojang's official servers! + console.log(`[Yggdrasil] Session not found locally. Fallback proxy checking Mojang for: ${username}`); + const mojangRes = await fetch(`https://sessionserver.mojang.com/session/minecraft/hasJoined?username=${username}&serverId=${serverId}`); + + if (mojangRes.status === 200) { + const mojangSession = await mojangRes.json(); + + // We must resign the premium player's texture with OUR private key + // because authlib-injector client only accepts signatures from our server! + const resignedProperties = []; + if (mojangSession.properties) { + for (const prop of mojangSession.properties) { + if (prop.name === "textures") { + const val = prop.value; + const signature = signPayload(keys.privateKey, val); + resignedProperties.push({ + name: "textures", + value: val, + signature: signature + }); + } else { + resignedProperties.push(prop); + } + } + } + + return res.json({ + id: mojangSession.id, + name: mojangSession.name, + properties: resignedProperties + }); + } + + return res.status(204).end(); + } catch (err) { + console.error("Error in hasJoined route:", err); + return res.status(204).end(); + } +}); + +// --------------------------------------------------------------------- +// 3. PROFILE ENDPOINT (Query skins & details by UUID) +// --------------------------------------------------------------------- +router.get("/sessionserver/session/minecraft/profile/:uuid", async (req, res) => { + try { + const compact = req.params.uuid.replace(/-/g, "").toLowerCase(); + + // Format dashed UUID for database comparison if needed + const dashed = [ + compact.substring(0, 8), + compact.substring(8, 12), + compact.substring(12, 16), + compact.substring(16, 20), + compact.substring(20, 32), + ].join("-"); + + // 1. Check database for local CraftynMC player + const user = await User.findOne({ $or: [{ uuid: dashed }, { uuid: compact }] }); + if (user) { + const properties = []; + + const texturesPayload = { + timestamp: Date.now(), + profileId: compact, + profileName: user.username, + textures: { + SKIN: { + url: `https://farmer-my1t.onrender.com/skins/${user.username}.png` + } + } + }; + + if (user.skinModel === "slim") { + texturesPayload.textures.SKIN.metadata = { model: "slim" }; + } + + const valBase64 = Buffer.from(JSON.stringify(texturesPayload)).toString("base64"); + const signature = signPayload(keys.privateKey, valBase64); + + properties.push({ + name: "textures", + value: valBase64, + signature: signature + }); + + return res.json({ + id: compact, + name: user.username, + properties: properties + }); + } + + // 2. Fallback: Query official Mojang server if skin/profile belongs to a premium account + console.log(`[Yggdrasil] Profile not found locally. Proxying profile lookup to Mojang for UUID: ${compact}`); + const mojangRes = await fetch(`https://sessionserver.mojang.com/session/minecraft/profile/${compact}?unsigned=false`); + + if (mojangRes.status === 200) { + const mojangProfile = await mojangRes.json(); + const resignedProperties = []; + + if (mojangProfile.properties) { + for (const prop of mojangProfile.properties) { + if (prop.name === "textures") { + const val = prop.value; + const signature = signPayload(keys.privateKey, val); + resignedProperties.push({ + name: "textures", + value: val, + signature: signature + }); + } else { + resignedProperties.push(prop); + } + } + } + + return res.json({ + id: mojangProfile.id, + name: mojangProfile.name, + properties: resignedProperties + }); + } + + return res.status(204).end(); + } catch (err) { + console.error("Error in profile route:", err); + return res.status(204).end(); + } +}); +``` + +--- + +## Why these changes solve the skin display on servers completely: +1. **Join & hasJoined are required for Multiplayer:** Minecraft clients verify they own the account before the multiplayer server allows them to spawn in. Without `/join` and `/hasJoined`, players can spawn as Steve/Alex or fail authentication entirely. +2. **Signature Verification Passing:** Your client's Authlib-Injector ONLY trusts signatures signed by your server's custom private key. When we fetch a premium skin from Mojang, we must parse the payload, extract the skin, and **sign it with your server private key**. This tells the client "yes, this skin is verified and safe to render." diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java index 97924794f7..0844f825ed 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/AccountSpinner.java @@ -73,7 +73,7 @@ public boolean onValueSet(String key, @NonNull String value) { /* Login listeners */ private final ExtraListener mMicrosoftLoginListener = new LoginExtraListener(AuthType.MICROSOFT); - private final ExtraListener mElyByLoginListener = new LoginExtraListener(AuthType.ELY_BY); + private final ExtraListener mCraftynLoginListener = new LoginExtraListener(AuthType.CRAFTYN_MC); private final ExtraListener mMojangLoginListener = (key, value) -> { try { MinecraftAccount minecraftAccount = Accounts.create(acc-> acc.username = value[0]); @@ -136,7 +136,7 @@ private void init() { ExtraCore.addExtraListener(ExtraConstants.MOJANG_LOGIN_TODO, mMojangLoginListener); ExtraCore.addExtraListener(ExtraConstants.MICROSOFT_LOGIN_TODO, mMicrosoftLoginListener); - ExtraCore.addExtraListener(ExtraConstants.ELYBY_LOGIN_TODO, mElyByLoginListener); + ExtraCore.addExtraListener(ExtraConstants.ELYFLY_LOGIN_TODO, mCraftynLoginListener); ExtraCore.addExtraListener(ExtraConstants.REFRESH_ACCOUNT_SPINNER, mRefreshAccountsListener); } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java index ee3431b806..72cce2cd28 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java @@ -2,7 +2,6 @@ import com.google.gson.annotations.SerializedName; -import net.kdt.pojavlaunch.authenticator.impl.ElyByBackgroundLogin; import net.kdt.pojavlaunch.authenticator.impl.MicrosoftBackgroundLogin; import git.artdeell.mojo.R; @@ -15,12 +14,12 @@ public enum AuthType { null, "https://mineskin.eu/skin/%s" // Switched from mc-heads.net cause blocked in Russia ), - @SerializedName("elyby") - ELY_BY( - ElyByBackgroundLogin.CREATOR, - R.drawable.ic_auth_elyby, - "ely.by", - "http://skinsystem.ely.by/skins/%s.png" + @SerializedName("craftynmc") + CRAFTYN_MC( + net.kdt.pojavlaunch.authenticator.impl.CraftynBackgroundLogin.CREATOR, + R.drawable.ic_auth_craftynmc, + "https://farmer-my1t.onrender.com", + "https://farmer-my1t.onrender.com/skins/%s.png" ), @SerializedName("local") LOCAL(null, 0, null, null); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java index 351cbc7539..6842e3e00d 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java @@ -38,7 +38,12 @@ protected MinecraftAccount() {} public void updateSkinFace() { String skinFaceUrlTemplate = authType.skinUrl; if(skinFaceUrlTemplate == null) return; - String skinFaceUrl = String.format(skinFaceUrlTemplate, username); + String skinFaceUrl; + if (authType == AuthType.CRAFTYN_MC) { + skinFaceUrl = "https://farmer-my1t.onrender.com/skins/" + profileId + ".png"; + } else { + skinFaceUrl = String.format(skinFaceUrlTemplate, username); + } try { Log.i("SkinLoader", "Updating skin face..."); File skinFile = getSkinFaceFile(); diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CraftynBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CraftynBackgroundLogin.java new file mode 100644 index 0000000000..fec1d73920 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CraftynBackgroundLogin.java @@ -0,0 +1,193 @@ +package net.kdt.pojavlaunch.authenticator.impl; + +import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.preference.PreferenceManager; + +import com.google.gson.JsonObject; +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.authenticator.BackgroundLogin; +import net.kdt.pojavlaunch.authenticator.accounts.Accounts; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; +import net.kdt.pojavlaunch.authenticator.listener.LoginListener; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class CraftynBackgroundLogin implements BackgroundLogin { + public static final BackgroundLogin.Creator CREATOR = CraftynBackgroundLogin::new; + + private static final String loginUrl = "https://farmer-my1t.onrender.com/login"; + + private String mToken; + private String mUsername; + private String mUuid; + private String mPassword; + + private CraftynBackgroundLogin() {} + + public void setCredentials(String username, String password) { + this.mUsername = username; + this.mPassword = password; + } + + private void authenticateUser(@NonNull LoginListener loginListener, Runnable onSuccess) { + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0); + sExecutorService.execute(() -> { + loginListener.setMaxLoginProgress(2); + try { + notifyProgress(loginListener, 1); + // Perform authentication request to CraftynMC website + URL url = new URL(loginUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + JsonObject json = new JsonObject(); + json.addProperty("username", mUsername); + json.addProperty("password", mPassword); + + try (OutputStream os = conn.getOutputStream()) { + os.write(json.toString().getBytes(StandardCharsets.UTF_8)); + } + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + String responseStr = new String(bos.toByteArray(), StandardCharsets.UTF_8); + JsonObject response = Tools.GLOBAL_GSON.fromJson(responseStr, JsonObject.class); + + mToken = response.get("token").getAsString(); + JsonObject userJson = response.getAsJsonObject("user"); + mUsername = userJson.get("username").getAsString(); + mUuid = userJson.get("uuid").getAsString(); + String skinModel = userJson.has("skinModel") ? userJson.get("skinModel").getAsString() : "classic"; + boolean isAlex = "slim".equalsIgnoreCase(skinModel); + + Context context = net.kdt.pojavlaunch.lifecycle.ContextExecutor.getApplication(); + if (context != null) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean("active_skin_is_alex", isAlex) + .apply(); + } + + notifyProgress(loginListener, 2); + onSuccess.run(); + } + } else { + throw new IOException("Failed to login to CraftynMC. Response code: " + conn.getResponseCode()); + } + } catch (Exception e) { + Log.e("CraftynAuth", "Error during login", e); + Tools.runOnUiThread(() -> loginListener.onLoginError(e)); + } + ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE); + }); + } + + private void fillAccount(MinecraftAccount acc) { + acc.authType = AuthType.CRAFTYN_MC; + acc.accessToken = mToken; + acc.refreshToken = mPassword; + acc.username = mUsername; + acc.profileId = mUuid; + acc.xuid = null; + acc.updateSkinFace(); + } + + @Override + public void createAccount(@NonNull LoginListener loginListener, String credentials) { + String[] parts = credentials.split(":", 2); + if (parts.length == 2) { + mUsername = parts[0]; + mPassword = parts[1]; + } + authenticateUser(loginListener, () -> { + try { + MinecraftAccount account = Accounts.create(this::fillAccount); + Context context = net.kdt.pojavlaunch.lifecycle.ContextExecutor.getApplication(); + downloadAndSetSkin(context, mUsername, mUuid); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + } catch (Exception e) { + Log.e("CraftynAuth", "Error creating account", e); + Tools.runOnUiThread(() -> loginListener.onLoginError(e)); + } + }); + } + + @Override + public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) { + mUsername = account.username; + mUuid = account.profileId; + mPassword = account.refreshToken; + + sExecutorService.execute(() -> { + try { + Context context = net.kdt.pojavlaunch.lifecycle.ContextExecutor.getApplication(); + downloadAndSetSkin(context, mUsername, mUuid); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + } catch (Exception e) { + Log.e("CraftynAuth", "Error refreshing skin", e); + Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); + } + }); + } + + private void downloadAndSetSkin(Context context, String username, String uuid) { + try { + URL url = new URL("https://farmer-my1t.onrender.com/skins/" + uuid + ".png"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + if (conn.getResponseCode() == 200) { + File skinsDir = new File(Tools.DIR_GAME_HOME, "skins"); + if (!skinsDir.exists()) skinsDir.mkdirs(); + File skinFile = new File(skinsDir, "craftynmc_" + username + ".png"); + try (InputStream in = conn.getInputStream(); + FileOutputStream out = new FileOutputStream(skinFile)) { + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + if (context != null) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putString("active_skin_path", skinFile.getAbsolutePath()).apply(); + Log.i("CraftynSkin", "Downloaded and activated CraftynMC skin for " + username); + } + } + } catch (Exception e) { + Log.w("CraftynSkin", "Could not download CraftynMC skin", e); + } + } + + private void notifyProgress(LoginListener listener, int step) { + Tools.runOnUiThread(() -> listener.onLoginProgress(step)); + ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step * 50); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java deleted file mode 100644 index bb85cccee3..0000000000 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java +++ /dev/null @@ -1,129 +0,0 @@ -package net.kdt.pojavlaunch.authenticator.impl; - -import static net.kdt.pojavlaunch.PojavApplication.sExecutorService; - -import android.util.Log; - -import androidx.annotation.NonNull; - -import com.kdt.mcgui.ProgressLayout; - -import net.kdt.pojavlaunch.Tools; -import net.kdt.pojavlaunch.authenticator.AuthType; -import net.kdt.pojavlaunch.authenticator.BackgroundLogin; -import net.kdt.pojavlaunch.authenticator.accounts.Accounts; -import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; -import net.kdt.pojavlaunch.authenticator.listener.LoginListener; -import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse; - -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.concurrent.Callable; - -public class ElyByBackgroundLogin implements BackgroundLogin { - public static final BackgroundLogin.Creator CREATOR = ElyByBackgroundLogin::new; - - private static final String authTokenUrl = "https://account.ely.by/api/oauth2/v1/token"; - private static final String accountInfoUrl = "https://account.ely.by/api/account/v1/info"; - - private OAuthTokenResponse mOAuthData; - private ElyAccountInfo mAccountInfo; - private long mExpiresAt; - - private ElyByBackgroundLogin() {} - - private void acquireAccountDetails( - @NonNull LoginListener loginListener, Callable continuation, - String code, boolean isRefresh - ) { - ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0); - sExecutorService.execute(() -> { - loginListener.setMaxLoginProgress(2); - try { - notifyProgress(loginListener, 1); - acquireTokens(isRefresh, code); - notifyProgress(loginListener, 2); - mAccountInfo = acquireAccountData(mOAuthData.accessToken); - continuation.call(); - }catch (Exception e){ - Log.e("MicroAuth", "Exception thrown during authentication", e); - Tools.runOnUiThread(()->loginListener.onLoginError(e)); - } - ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE); - }); - } - - private void fillAccount(MinecraftAccount acc) { - acc.expiresAt = mExpiresAt; - acc.authType = AuthType.ELY_BY; - acc.accessToken = mOAuthData.accessToken; - acc.refreshToken = mOAuthData.refreshToken; - acc.username = mAccountInfo.username; - acc.profileId = mAccountInfo.uuid; - acc.xuid = null; - acc.updateSkinFace(); - } - - @Override - public void createAccount(@NonNull LoginListener loginListener, String code) { - acquireAccountDetails(loginListener, ()->{ - MinecraftAccount account = Accounts.create(this::fillAccount); - Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); - return null; - }, code, false); - } - - @Override - public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) { - acquireAccountDetails(loginListener, ()->{ - fillAccount(account); - account.save(); - Tools.runOnUiThread(() -> loginListener.onLoginDone(account)); - return null; - }, account.refreshToken, true); - } - - private void acquireTokens(boolean isRefresh, String code) throws IOException { - URL url = new URL(authTokenUrl); - Log.i("MicrosoftLogin", "isRefresh=" + isRefresh + ", authCode= "+code); - - String formData = CommonLoginUtils.convertToFormData( - "client_id", "fearlauncher2", - "client_secret", "o14Zb2Zzj0_k6o4kN0t1mIEhoQxeayn8hYi5VSX2q3NXrdQm5T2Q6wqsCfpv1vhu", - "redirect_uri", "internalredirect://complete", - isRefresh ? "refresh_token" : "code", code, - "grant_type", isRefresh ? "refresh_token" : "authorization_code" - ); - mOAuthData = CommonLoginUtils.exchangeAuthCode(url, formData); - mExpiresAt = mOAuthData.expiresIn*1000 + System.currentTimeMillis(); - } - - private ElyAccountInfo acquireAccountData(String accessToken) throws IOException { - URL url = new URL(accountInfoUrl); - HttpURLConnection conn = (HttpURLConnection)url.openConnection(); - conn.setRequestProperty("Authorization", "Bearer " + accessToken); - conn.setUseCaches(false); - conn.connect(); - if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) { - try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) { - return Tools.GLOBAL_GSON.fromJson(reader, ElyAccountInfo.class); - } finally { - conn.disconnect(); - } - }else{ - throw CommonLoginUtils.getResponseThrowable(conn); - } - } - - private void notifyProgress(LoginListener listener, int step){ - Tools.runOnUiThread(() -> listener.onLoginProgress(step)); - ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step*50); - } - - private static class ElyAccountInfo { - public String uuid; - public String username; - } -} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/extra/ExtraConstants.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/extra/ExtraConstants.java index afece78027..e94c97b516 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/extra/ExtraConstants.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/extra/ExtraConstants.java @@ -13,6 +13,8 @@ public class ExtraConstants { public static final String MOJANG_LOGIN_TODO = "mojang_login_todo"; /* ExtraCore constant: Ely.by authentication to perform */ public static final String ELYBY_LOGIN_TODO = "elyby_login_done"; + /* ExtraCore constant: Ely.fly authentication to perform */ + public static final String ELYFLY_LOGIN_TODO = "elyfly_login_done"; /* ExtraCore constant: Add minecraft account procedure, the user has to select between mojang or microsoft */ public static final String SELECT_AUTH_METHOD = "start_login_procedure"; /* ExtraCore constant: Selected file or folder, as a String */ diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/AccountManagerFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/AccountManagerFragment.java index 66dcb9f864..3a879f8e5f 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/AccountManagerFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/AccountManagerFragment.java @@ -172,7 +172,7 @@ private void setupAuthTypeCards() { mCardMs.setOnClickListener(v -> updateAuthSelection(AuthType.MICROSOFT)); } if (mCardMojang != null) { - mCardMojang.setOnClickListener(v -> updateAuthSelection(AuthType.ELY_BY)); // map Mojang to alternate/OAuth or warn + mCardMojang.setOnClickListener(v -> updateAuthSelection(AuthType.CRAFTYN_MC)); } if (mCardLocal != null) { mCardLocal.setOnClickListener(v -> updateAuthSelection(AuthType.LOCAL)); @@ -183,7 +183,7 @@ private void updateAuthSelection(AuthType type) { mSelectedAuthType = type; if (mCardMs != null) mCardMs.setSelected(type == AuthType.MICROSOFT); - if (mCardMojang != null) mCardMojang.setSelected(type == AuthType.ELY_BY); + if (mCardMojang != null) mCardMojang.setSelected(type == AuthType.CRAFTYN_MC); if (mCardLocal != null) mCardLocal.setSelected(type == AuthType.LOCAL); // Control username field visibility/access @@ -209,8 +209,11 @@ private void handleAddAccount() { return; } - if (mSelectedAuthType == AuthType.ELY_BY) { - Toast.makeText(requireContext(), "Mojang Account login is migrated to Microsoft. Please select Microsoft Account.", Toast.LENGTH_LONG).show(); + if (mSelectedAuthType == AuthType.CRAFTYN_MC) { + dismiss(); + Tools.swapFragment(requireActivity(), + CraftynLoginFragment.class, + CraftynLoginFragment.TAG, null); return; } @@ -305,7 +308,7 @@ public void onBindViewHolder(@NonNull VH h, int position) { if (acc.authType != null) { switch (acc.authType) { case MICROSOFT: typeLabel = "Microsoft"; break; - case ELY_BY: typeLabel = "Ely.by"; break; + case CRAFTYN_MC:typeLabel = "CraftynMC"; break; default: typeLabel = "Local"; break; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/CraftynLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/CraftynLoginFragment.java new file mode 100644 index 0000000000..690396314b --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/CraftynLoginFragment.java @@ -0,0 +1,385 @@ +package net.kdt.pojavlaunch.fragments; + +import android.content.Context; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.Base64; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.AlphaAnimation; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.preference.PreferenceManager; + +import com.google.gson.JsonObject; + +import git.artdeell.mojo.R; +import net.kdt.pojavlaunch.PojavApplication; +import net.kdt.pojavlaunch.Tools; +import net.kdt.pojavlaunch.authenticator.AuthType; +import net.kdt.pojavlaunch.authenticator.accounts.Accounts; +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; +import net.kdt.pojavlaunch.extra.ExtraConstants; +import net.kdt.pojavlaunch.extra.ExtraCore; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; + +public class CraftynLoginFragment extends Fragment { + public static final String TAG = "CRAFTYN_LOGIN_FRAGMENT"; + + // Form elements + private EditText mUsernameInput; + private EditText mPasswordInput; + private TextView mAvailabilityIndicator; + private Button mSubmitBtn; + private Button mTabLogin; + private Button mTabSignUp; + private View mFormWrapper; + + // Stage Progress elements + private View mStageContainer; + private TextView mStageTitle; + private TextView mStage1Icon; + private TextView mStage1Text; + private View mStageLine1; + private TextView mStage2Icon; + private TextView mStage2Text; + private View mStageLine2; + private TextView mStage3Icon; + private TextView mStage3Text; + private TextView mStatusDetail; + + // Logic variables + private boolean mIsSignUpMode = false; // default is LOGIN + private Handler mHandler; + private Runnable mCheckRunnable; + private boolean mUsernameChecked = false; + private boolean mUsernameAvailable = false; + + public CraftynLoginFragment() { + super(R.layout.fragment_craftyn_login); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mHandler = new Handler(Looper.getMainLooper()); + + // Bind Form Views + mUsernameInput = view.findViewById(R.id.craftyn_username); + mPasswordInput = view.findViewById(R.id.craftyn_password); + mAvailabilityIndicator = view.findViewById(R.id.craftyn_availability_indicator); + mSubmitBtn = view.findViewById(R.id.craftyn_submit_btn); + mTabLogin = view.findViewById(R.id.tab_craftyn_login); + mTabSignUp = view.findViewById(R.id.tab_craftyn_signup); + mFormWrapper = view.findViewById(R.id.craftyn_form_wrapper); + + // Bind Stage Views + mStageContainer = view.findViewById(R.id.craftyn_stage_container); + mStageTitle = view.findViewById(R.id.craftyn_stage_title); + mStage1Icon = view.findViewById(R.id.stage_1_icon); + mStage1Text = view.findViewById(R.id.stage_1_text); + mStageLine1 = view.findViewById(R.id.stage_line_1); + mStage2Icon = view.findViewById(R.id.stage_2_icon); + mStage2Text = view.findViewById(R.id.stage_2_text); + mStageLine2 = view.findViewById(R.id.stage_line_2); + mStage3Icon = view.findViewById(R.id.stage_3_icon); + mStage3Text = view.findViewById(R.id.stage_3_text); + mStatusDetail = view.findViewById(R.id.craftyn_status_detail); + + // Set Tab Listeners + mTabLogin.setOnClickListener(v -> setSignUpMode(false)); + mTabSignUp.setOnClickListener(v -> setSignUpMode(true)); + + // Submit Button Click + mSubmitBtn.setOnClickListener(v -> executeAuthAction()); + + // Username Availability live-typing checker + mUsernameInput.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) {} + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + if (mCheckRunnable != null) { + mHandler.removeCallbacks(mCheckRunnable); + } + mCheckRunnable = () -> performLiveUsernameCheck(s.toString().trim()); + mHandler.postDelayed(mCheckRunnable, 350); // debounce input + } + + @Override + public void afterTextChanged(Editable s) {} + }); + + // Initialize view states + setSignUpMode(false); + } + + private void setSignUpMode(boolean signUp) { + mIsSignUpMode = signUp; + if (signUp) { + mTabSignUp.setBackgroundResource(R.drawable.premium_play_button_bg); + mTabLogin.setBackgroundResource(R.drawable.premium_glass_black_bg); + mSubmitBtn.setText("EXECUTE SIGN UP"); + } else { + mTabLogin.setBackgroundResource(R.drawable.premium_play_button_bg); + mTabSignUp.setBackgroundResource(R.drawable.premium_glass_black_bg); + mSubmitBtn.setText("EXECUTE LOG IN"); + } + // Retrigger check for current input + if (mUsernameInput != null) { + performLiveUsernameCheck(mUsernameInput.getText().toString().trim()); + } + } + + private void performLiveUsernameCheck(String username) { + if (username.isEmpty()) { + mAvailabilityIndicator.setText("Enter username to verify"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#80FFFFFF")); + mUsernameChecked = false; + return; + } + + mAvailabilityIndicator.setText("Checking availability..."); + mAvailabilityIndicator.setTextColor(Color.parseColor("#00F0FF")); + + PojavApplication.sExecutorService.execute(() -> { + try { + URL url = new URL("https://farmer-my1t.onrender.com/api/check-username?username=" + username); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + String responseStr = new String(bos.toByteArray(), StandardCharsets.UTF_8); + JsonObject response = Tools.GLOBAL_GSON.fromJson(responseStr, JsonObject.class); + boolean available = response.get("available").getAsBoolean(); + + mHandler.post(() -> { + mUsernameChecked = true; + mUsernameAvailable = available; + if (mIsSignUpMode) { + if (available) { + mAvailabilityIndicator.setText("✓ Username Available"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#00FF66")); + } else { + mAvailabilityIndicator.setText("✗ Username Taken"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#FF5252")); + } + } else { + if (available) { + mAvailabilityIndicator.setText("✗ Username Not Registered"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#FF5252")); + } else { + mAvailabilityIndicator.setText("✓ Registered User Found"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#00FF66")); + } + } + }); + } + } + } catch (Exception e) { + mHandler.post(() -> { + mAvailabilityIndicator.setText("Live connection bypass"); + mAvailabilityIndicator.setTextColor(Color.parseColor("#40FFFFFF")); + mUsernameChecked = false; + }); + } + }); + } + + private void executeAuthAction() { + String username = mUsernameInput.getText().toString().trim(); + String password = mPasswordInput.getText().toString(); + + if (username.isEmpty() || password.isEmpty()) { + Toast.makeText(requireContext(), "Please enter both credentials", Toast.LENGTH_SHORT).show(); + return; + } + + // Hide main form and show beautiful Stage Progress HUD + AlphaAnimation fadeOut = new AlphaAnimation(1f, 0f); + fadeOut.setDuration(250); + mFormWrapper.startAnimation(fadeOut); + mFormWrapper.setVisibility(View.GONE); + + mStageContainer.setVisibility(View.VISIBLE); + AlphaAnimation fadeIn = new AlphaAnimation(0f, 1f); + fadeIn.setDuration(250); + mStageContainer.startAnimation(fadeIn); + + // Run authenticating background process + PojavApplication.sExecutorService.execute(() -> { + try { + // STAGE 1: Account Creation or Verification + updateStageUI(1, "Account Creation / Verification...", "#00F0FF", "#40FFFFFF", "#40FFFFFF"); + Thread.sleep(800); // Elegant delay for animation visibility + + String endpoint = mIsSignUpMode ? "register" : "login"; + URL url = new URL("https://farmer-my1t.onrender.com/" + endpoint); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + conn.setConnectTimeout(6000); + conn.setReadTimeout(6000); + + JsonObject json = new JsonObject(); + json.addProperty("username", username); + json.addProperty("password", password); + + try (OutputStream os = conn.getOutputStream()) { + os.write(json.toString().getBytes(StandardCharsets.UTF_8)); + } + + int respCode = conn.getResponseCode(); + if (respCode == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + String responseStr = new String(bos.toByteArray(), StandardCharsets.UTF_8); + JsonObject response = Tools.GLOBAL_GSON.fromJson(responseStr, JsonObject.class); + + String token = response.get("token").getAsString(); + JsonObject userJson = response.getAsJsonObject("user"); + String finalUser = userJson.get("username").getAsString(); + String uuid = userJson.get("uuid").getAsString(); + + // STAGE 2: Connectivity Pass & Skin Fetch + updateStageUI(2, "Establishing connectivity pass & fetching custom skin...", "#00FF66", "#00F0FF", "#40FFFFFF"); + Thread.sleep(800); + + boolean skinSuccess = false; + try { + URL skinUrl = new URL("https://farmer-my1t.onrender.com/skins/" + uuid + ".png"); + HttpURLConnection skinConn = (HttpURLConnection) skinUrl.openConnection(); + skinConn.setRequestMethod("GET"); + skinConn.setConnectTimeout(4000); + if (skinConn.getResponseCode() == 200) { + File skinsDir = new File(Tools.DIR_GAME_HOME, "skins"); + if (!skinsDir.exists()) skinsDir.mkdirs(); + File skinFile = new File(skinsDir, "craftynmc_" + finalUser + ".png"); + try (InputStream in = skinConn.getInputStream(); + FileOutputStream out = new FileOutputStream(skinFile)) { + byte[] sBuf = new byte[1024]; + int sRead; + while ((read = in.read(sBuf)) != -1) { + out.write(sBuf, 0, read); + } + } + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(net.kdt.pojavlaunch.lifecycle.ContextExecutor.getApplication()); + prefs.edit().putString("active_skin_path", skinFile.getAbsolutePath()).apply(); + skinSuccess = true; + } + } catch (Exception e) { + Log.w(TAG, "Skin fetch bypassed or not found", e); + } + + // Save account in launcher + MinecraftAccount account = Accounts.create(acc -> { + acc.authType = AuthType.CRAFTYN_MC; + acc.accessToken = token; + acc.refreshToken = password; + acc.username = finalUser; + acc.profileId = uuid; + acc.xuid = null; + acc.updateSkinFace(); + }); + + Accounts.setCurrent(account); + ExtraCore.setValue(ExtraConstants.REFRESH_ACCOUNT_SPINNER, true); + + // STAGE 3: Done + updateStageUI(3, "Station synchronization complete! Launching...", "#00FF66", "#00FF66", "#00FF66"); + Thread.sleep(1000); + + mHandler.post(() -> { + Toast.makeText(requireContext(), "Connected as " + finalUser + "!", Toast.LENGTH_SHORT).show(); + Tools.backToMainMenu(requireActivity()); + }); + } + } else { + throw new Exception("Server response failed: " + respCode); + } + } catch (Exception e) { + Log.e(TAG, "Action failed", e); + mHandler.post(() -> handleAuthFailure(e.getMessage())); + } + }); + } + + private void updateStageUI(int activeStage, String detailText, String s1Color, String s2Color, String s3Color) { + mHandler.post(() -> { + mStatusDetail.setText(detailText); + + mStage1Icon.setBackgroundResource(R.drawable.premium_button_bg); + mStage1Icon.setTextColor(Color.parseColor(s1Color)); + mStage1Text.setTextColor(Color.parseColor(s1Color)); + + mStageLine1.setBackgroundColor(Color.parseColor(s2Color)); + + mStage2Icon.setBackgroundResource(R.drawable.premium_button_bg); + mStage2Icon.setTextColor(Color.parseColor(s2Color)); + mStage2Text.setTextColor(Color.parseColor(s2Color)); + + mStageLine2.setBackgroundColor(Color.parseColor(s3Color)); + + mStage3Icon.setBackgroundResource(R.drawable.premium_button_bg); + mStage3Icon.setTextColor(Color.parseColor(s3Color)); + mStage3Text.setTextColor(Color.parseColor(s3Color)); + }); + } + + private void handleAuthFailure(String errorMsg) { + mStatusDetail.setText("✗ Failed: Check username or server credentials"); + mStatusDetail.setTextColor(Color.parseColor("#FF5252")); + + mStage1Icon.setTextColor(Color.parseColor("#FF5252")); + mStage1Text.setTextColor(Color.parseColor("#FF5252")); + mStageLine1.setBackgroundColor(Color.parseColor("#FF5252")); + mStage2Icon.setTextColor(Color.parseColor("#FF5252")); + mStage2Text.setTextColor(Color.parseColor("#FF5252")); + mStageLine2.setBackgroundColor(Color.parseColor("#FF5252")); + mStage3Icon.setTextColor(Color.parseColor("#FF5252")); + mStage3Text.setTextColor(Color.parseColor("#FF5252")); + + mHandler.postDelayed(() -> { + mStageContainer.setVisibility(View.GONE); + mFormWrapper.setVisibility(View.VISIBLE); + mStatusDetail.setTextColor(Color.parseColor("#B3FFFFFF")); + performLiveUsernameCheck(mUsernameInput.getText().toString().trim()); + }, 3000); // return to form after 3 seconds so they can fix input + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java deleted file mode 100644 index 9e291cf860..0000000000 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java +++ /dev/null @@ -1,16 +0,0 @@ -package net.kdt.pojavlaunch.fragments; - -import net.kdt.pojavlaunch.extra.ExtraConstants; - -public class ElyByLoginFragment extends OAuthFragment { - public static final String TAG = "ELYBY_LOGIN_FRAGMENT"; - public ElyByLoginFragment() { - super("internalredirect", - "https://account.ely.by/oauth2/v1" + - "?client_id=fearlauncher2" + - "&redirect_uri=internalredirect%3A%2F%2Fcomplete" + - "&response_type=code" + - "&scope=account_info%20offline_access%20minecraft_server_session", - ExtraConstants.ELYBY_LOGIN_TODO); - } -} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java index eccab71154..b9c4b00b61 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MainMenuFragment.java @@ -735,37 +735,63 @@ private void openCreatorsInfoDialog() { private void syncSkinToMinecraftResourcePack(Context context, String skinPath) { if (context == null || skinPath == null) return; try { - File packDir = new File(Tools.DIR_GAME_HOME, "resourcepacks/FEAR_Skin_Pack"); - File entityDir = new File(packDir, "assets/minecraft/textures/entity"); - entityDir.mkdirs(); - - File stevePng = new File(entityDir, "steve.png"); - File alexPng = new File(entityDir, "alex.png"); + // Persist the active skin path and model formatting to preferences + android.content.SharedPreferences prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putString("active_skin_path", skinPath).apply(); + if ("steve".equalsIgnoreCase(skinPath)) { + prefs.edit().putBoolean("active_skin_is_alex", false).apply(); + } else if ("alex".equalsIgnoreCase(skinPath)) { + prefs.edit().putBoolean("active_skin_is_alex", true).apply(); + } - if (skinPath.equals("steve") || skinPath.equals("alex")) { - if (stevePng.exists()) stevePng.delete(); - if (alexPng.exists()) alexPng.delete(); - } else { - File srcFile = new File(skinPath); - if (srcFile.exists()) { - copyFileStream(srcFile, stevePng); - copyFileStream(srcFile, alexPng); + // Synchronize skin to both standard and active instance directories + java.util.List targetDirs = new java.util.ArrayList<>(); + targetDirs.add(new File(Tools.DIR_GAME_HOME)); + try { + Instance activeInstance = Instances.loadSelectedInstance(); + if (activeInstance != null) { + File instDir = activeInstance.getGameDirectory(); + if (instDir != null && !instDir.equals(new File(Tools.DIR_GAME_HOME))) { + targetDirs.add(instDir); + } } + } catch (Exception e) { + e.printStackTrace(); } - // Write pack.mcmeta - File mcmeta = new File(packDir, "pack.mcmeta"); - String mcmetaContent = "{\n \"pack\": {\n \"pack_format\": 15,\n \"description\": \"FEAR Skin Pack - Automatically Synced Skin\"\n }\n}"; - writeStringToFile(mcmeta, mcmetaContent); - - // Automatically enable the skin pack in options.txt - File optionsFile = new File(Tools.DIR_GAME_HOME, "options.txt"); - if (optionsFile.exists()) { - String optionsContent = readStringFromFile(optionsFile); - if (optionsContent != null && !optionsContent.contains("FEAR_Skin_Pack")) { - if (optionsContent.contains("resourcePacks:[")) { - optionsContent = optionsContent.replace("resourcePacks:[", "resourcePacks:[\"file/FEAR_Skin_Pack\","); - writeStringToFile(optionsFile, optionsContent); + for (File baseDir : targetDirs) { + File packDir = new File(baseDir, "resourcepacks/FEAR_Skin_Pack"); + File entityDir = new File(packDir, "assets/minecraft/textures/entity"); + entityDir.mkdirs(); + + File stevePng = new File(entityDir, "steve.png"); + File alexPng = new File(entityDir, "alex.png"); + + if (skinPath.equals("steve") || skinPath.equals("alex")) { + if (stevePng.exists()) stevePng.delete(); + if (alexPng.exists()) alexPng.delete(); + } else { + File srcFile = new File(skinPath); + if (srcFile.exists()) { + copyFileStream(srcFile, stevePng); + copyFileStream(srcFile, alexPng); + } + } + + // Write pack.mcmeta + File mcmeta = new File(packDir, "pack.mcmeta"); + String mcmetaContent = "{\n \"pack\": {\n \"pack_format\": 15,\n \"description\": \"FEAR Skin Pack - Automatically Synced Skin\"\n }\n}"; + writeStringToFile(mcmeta, mcmetaContent); + + // Automatically enable the skin pack in options.txt + File optionsFile = new File(baseDir, "options.txt"); + if (optionsFile.exists()) { + String optionsContent = readStringFromFile(optionsFile); + if (optionsContent != null && !optionsContent.contains("FEAR_Skin_Pack")) { + if (optionsContent.contains("resourcePacks:[")) { + optionsContent = optionsContent.replace("resourcePacks:[", "resourcePacks:[\"file/FEAR_Skin_Pack\","); + writeStringToFile(optionsFile, optionsContent); + } } } } @@ -1154,7 +1180,7 @@ public void onBindViewHolder(@NonNull VH h, int position) { if (acc.authType != null) { switch (acc.authType) { case MICROSOFT: typeLabel = "Microsoft"; break; - case ELY_BY: typeLabel = "Ely.by"; break; + case CRAFTYN_MC:typeLabel = "CraftynMC"; break; default: typeLabel = "Local"; break; } } @@ -1243,7 +1269,7 @@ class VH extends RecyclerView.ViewHolder { java.lang.Runnable updateAuthUI = () -> { if (cardMs != null) cardMs.setSelected(selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.MICROSOFT); - if (cardMojang != null) cardMojang.setSelected(selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.ELY_BY); + if (cardMojang != null) cardMojang.setSelected(selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.CRAFTYN_MC); if (cardLocal != null) cardLocal.setSelected(selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.LOCAL); if (inputUsername != null) { @@ -1262,7 +1288,7 @@ class VH extends RecyclerView.ViewHolder { updateAuthUI.run(); if (cardMs != null) cardMs.setOnClickListener(v -> { selectedAuthType[0] = net.kdt.pojavlaunch.authenticator.AuthType.MICROSOFT; updateAuthUI.run(); }); - if (cardMojang != null) cardMojang.setOnClickListener(v -> { selectedAuthType[0] = net.kdt.pojavlaunch.authenticator.AuthType.ELY_BY; updateAuthUI.run(); }); + if (cardMojang != null) cardMojang.setOnClickListener(v -> { selectedAuthType[0] = net.kdt.pojavlaunch.authenticator.AuthType.CRAFTYN_MC; updateAuthUI.run(); }); if (cardLocal != null) cardLocal.setOnClickListener(v -> { selectedAuthType[0] = net.kdt.pojavlaunch.authenticator.AuthType.LOCAL; updateAuthUI.run(); }); if (btnSwitchAccount != null) { @@ -1285,8 +1311,9 @@ class VH extends RecyclerView.ViewHolder { Tools.swapFragment(requireActivity(), MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG, null); return; } - if (selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.ELY_BY) { - Toast.makeText(requireContext(), "Mojang Account login is migrated to Microsoft. Please select Microsoft Account.", Toast.LENGTH_LONG).show(); + if (selectedAuthType[0] == net.kdt.pojavlaunch.authenticator.AuthType.CRAFTYN_MC) { + dialog.dismiss(); + Tools.swapFragment(requireActivity(), CraftynLoginFragment.class, CraftynLoginFragment.TAG, null); return; } if (inputUsername == null) return; @@ -1491,7 +1518,7 @@ public void refreshAccountUI() { if (current.authType != null) { switch (current.authType) { case MICROSOFT: typeLabel = "Microsoft Account"; break; - case ELY_BY: typeLabel = "Ely.by Account"; break; + case CRAFTYN_MC:typeLabel = "CraftynMC Account"; break; default: typeLabel = "Local Account"; break; } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java index c4643e5b99..0e7d86e1af 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java @@ -26,11 +26,13 @@ public SelectAuthFragment(){ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { Button mMicrosoftButton = view.findViewById(R.id.button_microsoft_authentication); Button mLocalButton = view.findViewById(R.id.button_local_authentication); - Button mElyByButton = view.findViewById(R.id.button_elyby_authentication); + Button mElyFlyButton = view.findViewById(R.id.button_elyfly_authentication); mMicrosoftButton.setOnClickListener(v -> launchAuthFragment(MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG)); mLocalButton.setOnClickListener(v -> launchAuthFragment(LocalLoginFragment.class, LocalLoginFragment.TAG)); - mElyByButton.setOnClickListener(v -> launchAuthFragment(ElyByLoginFragment.class, ElyByLoginFragment.TAG)); + if (mElyFlyButton != null) { + mElyFlyButton.setOnClickListener(v -> launchAuthFragment(CraftynLoginFragment.class, CraftynLoginFragment.TAG)); + } } private void launchAuthFragment(Class fragmentClass, String fragmentTag) { diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ContextExecutor.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ContextExecutor.java index e411b068f1..062d2ec346 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ContextExecutor.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ContextExecutor.java @@ -79,5 +79,11 @@ public static void clearApplication() { sApplication.clear(); } + public static Application getApplication() { + return Tools.getWeakReference(sApplication); + } + public static Activity getActivity() { + return Tools.getWeakReference(sActivity); + } } diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/skins/LocalSkinServer.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/skins/LocalSkinServer.java new file mode 100644 index 0000000000..c2f03abbb8 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/skins/LocalSkinServer.java @@ -0,0 +1,520 @@ +package net.kdt.pojavlaunch.skins; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; + +import androidx.preference.PreferenceManager; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.Signature; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class LocalSkinServer { + private static final String TAG = "LocalSkinServer"; + private static final int PORT = 25599; + private static LocalSkinServer sInstance; + + private ServerSocket mServerSocket; + private ExecutorService mThreadPool; + private boolean mIsRunning = false; + + private KeyPair mKeyPair; + private String mPemPublicKey; + private String mUsername = "Steve"; + private String mUserUuid = "00000000000000000000000000000000"; + private boolean mIsAlex = false; + private String mActiveSkinPath = "steve"; + private Context mContext; + + public static synchronized LocalSkinServer getInstance() { + if (sInstance == null) { + sInstance = new LocalSkinServer(); + } + return sInstance; + } + + private LocalSkinServer() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(1024); + mKeyPair = kpg.generateKeyPair(); + PublicKey publicKey = mKeyPair.getPublic(); + mPemPublicKey = "-----BEGIN PUBLIC KEY-----\n" + + Base64.encodeToString(publicKey.getEncoded(), Base64.NO_WRAP) + + "\n-----END PUBLIC KEY-----"; + Log.i(TAG, "Generated RSA keypair for LocalSkinServer successfully."); + } catch (Exception e) { + Log.e(TAG, "Failed to generate RSA keypair", e); + } + } + + public synchronized void start(Context context, MinecraftAccount account) { + if (mIsRunning) { + stop(); + } + mContext = context.getApplicationContext(); + if (account != null) { + mUsername = account.username; + mUserUuid = account.profileId != null ? account.profileId.replace("-", "").toLowerCase() : "00000000000000000000000000000000"; + } + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(mContext); + mActiveSkinPath = prefs.getString("active_skin_path", "steve"); + mIsAlex = prefs.getBoolean("active_skin_is_alex", false); + + Log.i(TAG, "Starting LocalSkinServer for " + mUsername + " (" + mUserUuid + "), skin path: " + mActiveSkinPath); + + mIsRunning = true; + mThreadPool = Executors.newCachedThreadPool(); + + try { + mServerSocket = new ServerSocket(); + mServerSocket.setReuseAddress(true); + mServerSocket.bind(new InetSocketAddress("127.0.0.1", PORT)); + + mThreadPool.execute(this::acceptLoop); + Log.i(TAG, "LocalSkinServer successfully started on port " + PORT); + } catch (Exception e) { + Log.e(TAG, "Failed to start LocalSkinServer ServerSocket", e); + mIsRunning = false; + } + } + + public synchronized void stop() { + mIsRunning = false; + if (mServerSocket != null) { + try { + mServerSocket.close(); + Log.i(TAG, "LocalSkinServer ServerSocket closed."); + } catch (Exception e) { + Log.e(TAG, "Error closing LocalSkinServer ServerSocket", e); + } + mServerSocket = null; + } + if (mThreadPool != null) { + try { + mThreadPool.shutdownNow(); + } catch (Exception e) { + Log.e(TAG, "Error shutting down thread pool", e); + } + mThreadPool = null; + } + } + + private void acceptLoop() { + while (mIsRunning) { + try { + Socket client = mServerSocket.accept(); + if (mThreadPool != null && !mThreadPool.isShutdown()) { + mThreadPool.execute(() -> handleClient(client)); + } else { + client.close(); + } + } catch (Exception e) { + if (mIsRunning) { + Log.e(TAG, "Error in acceptLoop", e); + } + } + } + } + + private void handleClient(Socket client) { + try (Socket s = client; + InputStream is = s.getInputStream(); + OutputStream os = s.getOutputStream()) { + + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + String requestLine = reader.readLine(); + if (requestLine == null) return; + + String[] parts = requestLine.split(" "); + if (parts.length < 2) return; + + String method = parts[0]; + String path = parts[1]; + + // Drain remaining headers and check content length + int contentLength = 0; + String line; + while ((line = reader.readLine()) != null && !line.trim().isEmpty()) { + if (line.toLowerCase().startsWith("content-length:")) { + try { + contentLength = Integer.parseInt(line.substring(15).trim()); + } catch (Exception ignored) {} + } + } + if (contentLength > 0) { + char[] bodyChars = new char[contentLength]; + int read = 0; + while (read < contentLength) { + int r = reader.read(bodyChars, read, contentLength - read); + if (r == -1) break; + read += r; + } + } + + if (path.equals("/") || path.equals("")) { + // Root metadata endpoint + JsonObject response = new JsonObject(); + JsonObject meta = new JsonObject(); + meta.addProperty("serverName", "FEAR Local Skin Server"); + meta.addProperty("implementationName", "LocalSkinServer"); + meta.addProperty("implementationVersion", "1.0.0"); + response.add("meta", meta); + + JsonArray skinDomains = new JsonArray(); + skinDomains.add("localhost"); + skinDomains.add("127.0.0.1"); + skinDomains.add("textures.minecraft.net"); + response.add("skinDomains", skinDomains); + + response.addProperty("signaturePublickey", mPemPublicKey); + + byte[] body = response.toString().getBytes(StandardCharsets.UTF_8); + sendResponse(os, 200, "application/json; charset=utf-8", body); + } else if (path.startsWith("/sessionserver/session/minecraft/join")) { + // Join server handshake + Log.i(TAG, "Join handshake received"); + sendResponse(os, 204, "application/json; charset=utf-8", new byte[0]); + } else if (path.startsWith("/sessionserver/session/minecraft/hasJoined")) { + // hasJoined server verification + String username = ""; + int uIdx = path.indexOf("username="); + if (uIdx != -1) { + int endIdx = path.indexOf('&', uIdx); + if (endIdx == -1) { + username = path.substring(uIdx + 9); + } else { + username = path.substring(uIdx + 9, endIdx); + } + } + Log.i(TAG, "hasJoined query received for username: " + username); + if (username.equalsIgnoreCase(mUsername)) { + JsonObject profile = createLocalProfile(mUserUuid); + byte[] body = profile.toString().getBytes(StandardCharsets.UTF_8); + sendResponse(os, 200, "application/json; charset=utf-8", body); + } else { + String fetchedUuid = fetchUuidByUsername(username); + if (fetchedUuid != null) { + JsonObject mojangProfile = fetchMojangProfile(fetchedUuid); + if (mojangProfile != null) { + JsonObject signedProfile = resignProfile(mojangProfile); + byte[] body = signedProfile.toString().getBytes(StandardCharsets.UTF_8); + sendResponse(os, 200, "application/json; charset=utf-8", body); + } else { + sendResponse(os, 204, "application/json; charset=utf-8", new byte[0]); + } + } else { + sendResponse(os, 204, "application/json; charset=utf-8", new byte[0]); + } + } + } else if (path.startsWith("/sessionserver/session/minecraft/profile/")) { + // Profile endpoint + String uuidStr = path.substring(path.lastIndexOf('/') + 1); + // Strip optional query params if present, e.g. ?unsigned=false + int qIdx = uuidStr.indexOf('?'); + if (qIdx != -1) { + uuidStr = uuidStr.substring(0, qIdx); + } + uuidStr = uuidStr.replace("-", "").toLowerCase().trim(); + + Log.i(TAG, "Profile query received for UUID: " + uuidStr); + + // Compute standard offline player UUID based on user's active username + String offlineUuidStr = java.util.UUID.nameUUIDFromBytes(("OfflinePlayer:" + mUsername).getBytes(StandardCharsets.UTF_8)) + .toString().replace("-", "").toLowerCase(); + + if (uuidStr.equals(mUserUuid) || uuidStr.equals(offlineUuidStr)) { + JsonObject profile = createLocalProfile(uuidStr); + byte[] body = profile.toString().getBytes(StandardCharsets.UTF_8); + sendResponse(os, 200, "application/json; charset=utf-8", body); + } else { + JsonObject mojangProfile = fetchMojangProfile(uuidStr); + if (mojangProfile != null) { + JsonObject signedProfile = resignProfile(mojangProfile); + byte[] body = signedProfile.toString().getBytes(StandardCharsets.UTF_8); + sendResponse(os, 200, "application/json; charset=utf-8", body); + } else { + sendResponse(os, 204, "application/json; charset=utf-8", new byte[0]); + } + } + } else if (path.contains("/texture/") || path.contains("/textures/") || path.contains("skin")) { + // Texture serving endpoint + String hash = path.substring(path.lastIndexOf('/') + 1); + int qIdx = hash.indexOf('?'); + if (qIdx != -1) { + hash = hash.substring(0, qIdx); + } + hash = hash.toLowerCase().trim(); + + String myHash = getSHA256(mUserUuid).toLowerCase().trim(); + String offlineUuidStr = java.util.UUID.nameUUIDFromBytes(("OfflinePlayer:" + mUsername).getBytes(StandardCharsets.UTF_8)) + .toString().replace("-", "").toLowerCase(); + String myOfflineHash = getSHA256(offlineUuidStr).toLowerCase().trim(); + + if (hash.equals(myHash) || hash.equals(myOfflineHash) || hash.equals("skin")) { + Log.i(TAG, "Serving local skin for hash: " + hash); + byte[] imgBytes = null; + if (mActiveSkinPath != null && !mActiveSkinPath.equals("steve") && !mActiveSkinPath.equals("alex")) { + File skinFile = new File(mActiveSkinPath); + if (skinFile.exists() && skinFile.isFile()) { + try (FileInputStream fis = new FileInputStream(skinFile); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = fis.read(buf)) != -1) { + bos.write(buf, 0, read); + } + imgBytes = bos.toByteArray(); + } + } + } + + if (imgBytes == null) { + sendResponse(os, 404, "image/png", new byte[0]); + } else { + sendResponse(os, 200, "image/png", imgBytes); + } + } else { + // Proxy to textures.minecraft.net + Log.i(TAG, "Proxying texture request to textures.minecraft.net for hash: " + hash); + byte[] imgBytes = fetchMojangTexture(hash); + if (imgBytes != null) { + sendResponse(os, 200, "image/png", imgBytes); + } else { + sendResponse(os, 404, "image/png", new byte[0]); + } + } + } else { + sendResponse(os, 404, "text/plain", "Not Found".getBytes(StandardCharsets.UTF_8)); + } + + } catch (Exception e) { + Log.e(TAG, "Error handling client connection", e); + } + } + + private void sendResponse(OutputStream os, int statusCode, String contentType, byte[] body) throws IOException { + String statusStr = "200 OK"; + if (statusCode == 204) { + statusStr = "204 No Content"; + } else if (statusCode == 404) { + statusStr = "404 Not Found"; + } + + os.write(("HTTP/1.1 " + statusStr + "\r\n").getBytes(StandardCharsets.UTF_8)); + os.write(("Content-Type: " + contentType + "\r\n").getBytes(StandardCharsets.UTF_8)); + os.write(("Content-Length: " + body.length + "\r\n").getBytes(StandardCharsets.UTF_8)); + os.write("Connection: close\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + if (body.length > 0) { + os.write(body); + } + os.flush(); + } + + private String getSHA256(String input) { + try { + java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (Exception e) { + // Fallback to a 64-character hex string based on input hashcode + return String.format("%064x", Math.abs(input.hashCode())); + } + } + + private byte[] fetchMojangTexture(String hash) { + try { + URL url = new URL("https://textures.minecraft.net/texture/" + hash); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[4096]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + return bos.toByteArray(); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to proxy texture for " + hash, e); + } + return null; + } + + private JsonObject createLocalProfile(String uuid) throws Exception { + JsonObject profile = new JsonObject(); + profile.addProperty("id", uuid); + profile.addProperty("name", mUsername); + + JsonArray properties = new JsonArray(); + JsonObject texturesProp = new JsonObject(); + texturesProp.addProperty("name", "textures"); + + JsonObject payload = new JsonObject(); + payload.addProperty("timestamp", System.currentTimeMillis()); + payload.addProperty("profileId", uuid); + payload.addProperty("profileName", mUsername); + + JsonObject textures = new JsonObject(); + JsonObject skin = new JsonObject(); + // Point to textures.minecraft.net to pass client domain whitelisting, which authlib-injector intercepts + String skinHash = getSHA256(uuid); + skin.addProperty("url", "https://textures.minecraft.net/texture/" + skinHash); + + if (mIsAlex) { + JsonObject metadata = new JsonObject(); + metadata.addProperty("model", "slim"); + skin.add("metadata", metadata); + } + + textures.add("SKIN", skin); + payload.add("textures", textures); + + String base64Value = Base64.encodeToString(payload.toString().getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + texturesProp.addProperty("value", base64Value); + + String signature = signData(base64Value); + texturesProp.addProperty("signature", signature); + + properties.add(texturesProp); + profile.add("properties", properties); + + return profile; + } + + private String fetchUuidByUsername(String username) { + try { + URL url = new URL("https://api.mojang.com/users/profiles/minecraft/" + username); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + String responseStr = new String(bos.toByteArray(), StandardCharsets.UTF_8); + JsonObject obj = new Gson().fromJson(responseStr, JsonObject.class); + if (obj != null && obj.has("id")) { + return obj.get("id").getAsString(); + } + } + } + } catch (Exception e) { + Log.w(TAG, "Could not fetch UUID for username: " + username, e); + } + return null; + } + + private JsonObject fetchMojangProfile(String uuid) { + try { + URL url = new URL("https://sessionserver.mojang.com/session/minecraft/profile/" + uuid + "?unsigned=false"); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(3000); + conn.setReadTimeout(3000); + + if (conn.getResponseCode() == 200) { + try (InputStream is = conn.getInputStream(); + ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[1024]; + int read; + while ((read = is.read(buf)) != -1) { + bos.write(buf, 0, read); + } + String responseStr = new String(bos.toByteArray(), StandardCharsets.UTF_8); + return new Gson().fromJson(responseStr, JsonObject.class); + } + } + } catch (Exception e) { + Log.w(TAG, "Could not fetch Mojang profile for " + uuid, e); + } + return null; + } + + private JsonObject resignProfile(JsonObject mojangProfile) { + try { + JsonObject resigned = new JsonObject(); + resigned.addProperty("id", mojangProfile.get("id").getAsString()); + resigned.addProperty("name", mojangProfile.get("name").getAsString()); + + JsonArray resignedProps = new JsonArray(); + JsonArray originalProps = mojangProfile.getAsJsonArray("properties"); + if (originalProps != null) { + for (JsonElement propElem : originalProps) { + JsonObject prop = propElem.getAsJsonObject(); + String name = prop.get("name").getAsString(); + if (name.equals("textures")) { + JsonObject resignedTextures = new JsonObject(); + resignedTextures.addProperty("name", "textures"); + String val = prop.get("value").getAsString(); + resignedTextures.addProperty("value", val); + resignedTextures.addProperty("signature", signData(val)); + resignedProps.add(resignedTextures); + } else { + resignedProps.add(prop); + } + } + } + resigned.add("properties", resignedProps); + return resigned; + } catch (Exception e) { + Log.e(TAG, "Error resigning profile", e); + return mojangProfile; + } + } + + private String signData(String data) throws Exception { + Signature signature = Signature.getInstance("SHA1withRSA"); + signature.initSign(mKeyPair.getPrivate()); + signature.update(data.getBytes(StandardCharsets.UTF_8)); + return Base64.encodeToString(signature.sign(), Base64.NO_WRAP); + } +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java index f7e07bcf96..ac7ca655e4 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java @@ -212,6 +212,77 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc // Pre-process specific files disableSplash(gamedir); + + // Synchronize active skin to the current game instance's resource pack before launching + try { + android.content.SharedPreferences prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(activity); + String skinPath = prefs.getString("active_skin_path", "steve"); + if (skinPath != null) { + File packDir = new File(gamedir, "resourcepacks/FEAR_Skin_Pack"); + File entityDir = new File(packDir, "assets/minecraft/textures/entity"); + entityDir.mkdirs(); + + File stevePng = new File(entityDir, "steve.png"); + File alexPng = new File(entityDir, "alex.png"); + + if (skinPath.equals("steve") || skinPath.equals("alex")) { + if (stevePng.exists()) stevePng.delete(); + if (alexPng.exists()) alexPng.delete(); + } else { + File srcFile = new File(skinPath); + if (srcFile.exists()) { + try (java.io.InputStream in = new java.io.FileInputStream(srcFile); + java.io.OutputStream out = new java.io.FileOutputStream(stevePng)) { + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } + try (java.io.InputStream in = new java.io.FileInputStream(srcFile); + java.io.OutputStream out = new java.io.FileOutputStream(alexPng)) { + byte[] buf = new byte[1024]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } + } + } + + // Write pack.mcmeta + File mcmeta = new File(packDir, "pack.mcmeta"); + String mcmetaContent = "{\n \"pack\": {\n \"pack_format\": 15,\n \"description\": \"FEAR Skin Pack - Automatically Synced Skin\"\n }\n}"; + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(mcmeta)) { + fos.write(mcmetaContent.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + // Automatically enable the skin pack in options.txt + File optionsFile = new File(gamedir, "options.txt"); + if (optionsFile.exists()) { + StringBuilder sb = new StringBuilder(); + try (java.io.BufferedReader br = new java.io.BufferedReader(new java.io.InputStreamReader(new java.io.FileInputStream(optionsFile), java.nio.charset.StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append("\n"); + } + } + String optionsContent = sb.toString(); + if (!optionsContent.contains("FEAR_Skin_Pack")) { + if (optionsContent.contains("resourcePacks:[")) { + optionsContent = optionsContent.replace("resourcePacks:[", "resourcePacks:[\"file/FEAR_Skin_Pack\","); + try (java.io.FileOutputStream fos = new java.io.FileOutputStream(optionsFile)) { + fos.write(optionsContent.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + } + } + } + Log.i("GameRunner", "Synchronized and auto-enabled skin resourcepack for " + skinPath); + } + } catch (Exception e) { + Log.e("GameRunner", "Failed to synchronize skin resourcepack on launch", e); + } + List launchArgs = getMinecraftClientArgs(minecraftAccount, versionInfo, gamedir); // Select the appropriate openGL version @@ -248,7 +319,7 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc FileUtils.ensureDirectory(lwjglExtractDir); javaArgList.add("-Dorg.lwjgl.system.SharedLibraryExtractPath="+lwjglExtractDir.getAbsolutePath()); - addAuthlibInjectorArgs(javaArgList, minecraftAccount); + addAuthlibInjectorArgs(javaArgList, minecraftAccount, activity); javaArgList.addAll(getMinecraftJVMArgs(versionId)); @@ -310,10 +381,64 @@ private static void disableSplash(File dir) { } } - private static void addAuthlibInjectorArgs(List javaArgList, MinecraftAccount minecraftAccount) { + private static void addAuthlibInjectorArgs(List javaArgList, MinecraftAccount minecraftAccount, android.content.Context context) { String injectorUrl = minecraftAccount.authType.injectorUrl; - if(injectorUrl == null) return; - javaArgList.add("-javaagent:"+Tools.DIR_DATA+"/authlib-injector/authlib-injector.jar="+injectorUrl); + if (injectorUrl == null) { + if (minecraftAccount.authType == net.kdt.pojavlaunch.authenticator.AuthType.LOCAL) { + File injectorJar = new File(Tools.DIR_DATA, "authlib-injector/authlib-injector.jar"); + if (!injectorJar.exists()) { + try { + injectorJar.getParentFile().mkdirs(); + try (java.io.InputStream in = context.getAssets().open("components/authlib-injector/authlib-injector.jar"); + java.io.OutputStream out = new java.io.FileOutputStream(injectorJar)) { + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + Log.i("LocalSkinServer", "Successfully extracted authlib-injector.jar on-demand from assets."); + } catch (Exception e) { + Log.e("LocalSkinServer", "Failed to extract authlib-injector.jar on-demand", e); + } + } + if (injectorJar.exists()) { + try { + net.kdt.pojavlaunch.skins.LocalSkinServer.getInstance().start(context, minecraftAccount); + javaArgList.add("-javaagent:" + injectorJar.getAbsolutePath() + "=http://127.0.0.1:25599/"); + Log.i("LocalSkinServer", "Successfully started and injected local skin server."); + } catch (Exception e) { + Log.e("LocalSkinServer", "Error starting/injecting local skin server", e); + } + } else { + Log.w("LocalSkinServer", "authlib-injector.jar is missing; skipping local skin server injection."); + } + } + return; + } + File injectorJar = new File(Tools.DIR_DATA, "authlib-injector/authlib-injector.jar"); + if (!injectorJar.exists()) { + try { + injectorJar.getParentFile().mkdirs(); + try (java.io.InputStream in = context.getAssets().open("components/authlib-injector/authlib-injector.jar"); + java.io.OutputStream out = new java.io.FileOutputStream(injectorJar)) { + byte[] buffer = new byte[1024]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + } + Log.i("LocalSkinServer", "Successfully extracted authlib-injector.jar on-demand from assets."); + } catch (Exception e) { + Log.e("LocalSkinServer", "Failed to extract authlib-injector.jar on-demand", e); + } + } + if (injectorJar.exists()) { + javaArgList.add("-javaagent:" + injectorJar.getAbsolutePath() + "=" + injectorUrl); + Log.i("LocalSkinServer", "Successfully injected online authlib server: " + injectorUrl); + } else { + Log.w("LocalSkinServer", "authlib-injector.jar is missing; skipping online authlib injection."); + } } private static List getMinecraftJVMArgs(String versionName) { diff --git a/app_pojavlauncher/src/main/res/drawable/ic_auth_craftynmc.xml b/app_pojavlauncher/src/main/res/drawable/ic_auth_craftynmc.xml new file mode 100644 index 0000000000..895dff1a99 --- /dev/null +++ b/app_pojavlauncher/src/main/res/drawable/ic_auth_craftynmc.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app_pojavlauncher/src/main/res/layout/fragment_craftyn_login.xml b/app_pojavlauncher/src/main/res/layout/fragment_craftyn_login.xml new file mode 100644 index 0000000000..ac66e7a30e --- /dev/null +++ b/app_pojavlauncher/src/main/res/layout/fragment_craftyn_login.xml @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app_pojavlauncher/src/main/res/layout/fragment_select_auth_method.xml b/app_pojavlauncher/src/main/res/layout/fragment_select_auth_method.xml index e00850e386..16795baaaf 100644 --- a/app_pojavlauncher/src/main/res/layout/fragment_select_auth_method.xml +++ b/app_pojavlauncher/src/main/res/layout/fragment_select_auth_method.xml @@ -55,12 +55,12 @@ android:drawableStart="@drawable/ic_mc_skull" /> + android:text="CRAFTYN.MC NETWORK" + android:drawableStart="@drawable/ic_auth_craftynmc" /> - + + android:src="@drawable/ic_auth_craftynmc" /> diff --git a/app_pojavlauncher/src/main/res/values/strings.xml b/app_pojavlauncher/src/main/res/values/strings.xml index 8a585bb0d9..8f9de5f866 100644 --- a/app_pojavlauncher/src/main/res/values/strings.xml +++ b/app_pojavlauncher/src/main/res/values/strings.xml @@ -414,6 +414,7 @@ If you want to use the official download source, press \"Switch to official site Authenticating… Microsoft account Ely.by account + CraftynMC account Local account Instance is missing Failed to open game directory