diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..af7cba23 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,90 @@ +name: Bug Report +description: Report a bug or broken feature +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + ## Before you continue + > **Failure to complete all checkboxes may result in your issue being closed and you being temporarily blocked from opening new issues.** + + - type: checkboxes + id: preflight + attributes: + label: Pre-submission checklist + description: You must check every box before submitting. + options: + - label: I have searched [existing issues](https://github.com/ReSo7200/InstaEclipse/issues) and this is **not a duplicate**. + required: true + - label: I am using Instagram from **APKMirror**, not the Google Play Store. + required: true + - label: I have tried **disabling and re-enabling** the module in LSPosed/LSPatch and force-stopping Instagram. + required: true + - label: I have read the [FAQ](https://github.com/ReSo7200/InstaEclipse#-faq) and my issue is not covered there. + required: true + - label: I understand that submitting duplicate issues or ignoring this checklist may result in being blocked from the repo. + required: true + + - type: input + id: instaeclipse_version + attributes: + label: InstaEclipse Version + placeholder: e.g. v0.5.0 + validations: + required: true + + - type: input + id: instagram_version + attributes: + label: Instagram Version + placeholder: e.g. 360.0.0.0.85 (from APKMirror) + validations: + required: true + + - type: dropdown + id: root_method + attributes: + label: Setup Method + options: + - Root — LSPosed (JingMatrix fork) + - Root — LSPosed (other fork) + - No Root — LSPatch (JingMatrix fork) + - No Root — LSPatch (other) + validations: + required: true + + - type: input + id: device + attributes: + label: Device & Android Version + placeholder: e.g. Pixel 8 — Android 15 + validations: + required: true + + - type: textarea + id: description + attributes: + label: What happened? + description: Describe the bug clearly. What did you expect to happen, and what actually happened? + placeholder: When I try to ... it does ... instead of ... + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to reproduce + placeholder: | + 1. Open Instagram + 2. Long-press search icon + 3. Enable ... + 4. See ... + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Logs (if available) + description: Paste any relevant LSPosed logs here. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..d5b3eea6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: 💬 Telegram Support Group + url: https://t.me/instaEclipse_discussion + about: For quick help, questions, and general discussion — join the Telegram group. + - name: 📢 Telegram Channel + url: https://t.me/InstaEclipse + about: Follow for updates and announcements. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..d971fdf8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,45 @@ +name: Feature Request +description: Suggest a new feature or improvement +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + ## Before you continue + > **Failure to complete all checkboxes may result in your issue being closed and you being temporarily blocked from opening new issues.** + + - type: checkboxes + id: preflight + attributes: + label: Pre-submission checklist + description: You must check every box before submitting. + options: + - label: I have searched [existing issues](https://github.com/ReSo7200/InstaEclipse/issues) and this feature has **not already been requested**. + required: true + - label: This is a feature request for **InstaEclipse**, not a general Instagram feature. + required: true + - label: I understand that submitting duplicate requests or ignoring this checklist may result in being blocked from the repo. + required: true + + - type: textarea + id: summary + attributes: + label: Feature summary + description: Describe the feature you'd like in one or two sentences. + placeholder: I'd like InstaEclipse to be able to ... + validations: + required: true + + - type: textarea + id: motivation + attributes: + label: Why is this useful? + description: Explain the use case — why would this benefit users? + validations: + required: true + + - type: textarea + id: details + attributes: + label: Additional details + description: Any implementation ideas, screenshots, or references to similar features in other mods. diff --git a/.gitignore b/.gitignore index 30d87eb8..af49b298 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ local.properties /app/release /app/debug +/.claude +/APK diff --git a/DISCLAIMER.md b/DISCLAIMER.md new file mode 100644 index 00000000..7feaec05 --- /dev/null +++ b/DISCLAIMER.md @@ -0,0 +1,27 @@ +# Disclaimer + +## No Affiliation + +InstaEclipse is an independent open-source project. It is **not affiliated with, endorsed by, sponsored by, or in any way associated with Meta Platforms, Inc. or Instagram.** + +"Instagram" is a registered trademark of Meta Platforms, Inc. Any reference to Instagram in this project is solely for descriptive purposes to identify the application this software is designed to work with. + +## No Warranty + +This software is provided **"as is"**, without warranty of any kind, express or implied. The authors and contributors accept no liability for any damages, data loss, account restrictions, bans, or any other consequences arising from the use of this software. + +## User Responsibility + +By using InstaEclipse, you acknowledge and agree that: + +- You are solely responsible for how you use this software. +- Use of this software may violate Instagram's Terms of Service. You accept full responsibility for any consequences, including but not limited to account suspension or permanent bans. +- The authors and contributors of InstaEclipse bear no responsibility for any action taken by Meta or Instagram against your account. + +## Right to Discontinue + +The maintainers of InstaEclipse reserve the right to discontinue, suspend, or remove this project — in whole or in part — at any time, for any reason, without prior notice and without any obligation to any user or third party. + +## Educational Purpose + +This project is intended for **educational and personal use only**. It is not intended to facilitate any activity that is illegal under applicable law. diff --git a/README.md b/README.md index 4b2c84db..52e7ec53 100644 --- a/README.md +++ b/README.md @@ -1,153 +1,278 @@ -

- InstaEclipse Logo -

+
+ InstaEclipse +

InstaEclipse

+

A powerful LSPosed module that enhances your Instagram experience.

+ +

+ GitHub Release + Stars + Downloads + Telegram + License +

+ +

+ Features • + Installation • + FAQ • + Contributors +

+
-# InstaEclipse ⚡ Enhance Your Instagram Experience! +--- -InstaEclipse is an **LSPosed module** designed to enhance your Instagram experience with advanced features like developer options, ghost mode, distraction-free mode, and more! 🚀 +## Overview -This module is built to stay compatible with **new Instagram releases** by leveraging dynamic analysis to locate targeted classes and methods automatically. 🧠✨ +**InstaEclipse** is an [LSPosed](https://github.com/JingMatrix/LSPosed) module built to unlock a richer Instagram experience — without root required (via [LSPatch](https://github.com/JingMatrix/LSPatch)). -[Telegram Channel](https://t.me/InstaEclipse) +It uses [DexKit](https://github.com/LuckyPray/DexKit) for dynamic class/method detection, keeping it compatible with new Instagram releases automatically. -
-

✨ Features

- -### 🎛️ Developer Options -- Access hidden developer tools within Instagram for advanced functionality. -- Import/Export config. -- **Note:** These options are intended for use with **Alpha** or **Beta** versions of Instagram. (Beta is better) - -### 👻 Ghost Mode -- Stay incognito while browsing stories, lives, or DMs. -- Mark messages as read (Hold on the Gallery icon inside the DM) -- No screenshot notifications sent. -- View "view once" media more than once. -- Hide your typing status in DMs. - -### 🧘 Distraction-Free Mode -- Enjoy Instagram without stories, reels, or explore feed distractions. -- **Important:** After enabling Distraction-Free Mode: - 1. **Force stop Instagram**. - 2. **Clear Instagram's cache**. - 3. Launch Instagram for a clean experience. - -### 🚫 Remove Ads -- Get rid of all Instagram ads. - -### 📉 Remove Analytics -- Block Instagram's tracking and analytics to protect your privacy. -- Prevent unnecessary data sharing and usage metrics. - -### 🔧 Misc Options -- Disable Auto Story Flipping. -- Disable Auto Play Videos. -- Follower indicator -
+--- +## ✨ Features
-

🛠️ Installation Instructions

+👻 Ghost Mode — Stay invisible without giving up functionality + +
+ +| Feature | Description | +|----------------------------------------------------|---| +| Hide DM Seen | Read messages without sending the read receipt | +| Hide Typing Indicator | Type freely without the other person knowing | +| Hide Story Views | View stories without appearing in the viewer list | +| Hide Live Presence | Join lives anonymously | +| Bypass Screenshot Detection | Take screenshots in DMs without triggering alerts | +| Allow Screenshots in DMs | Re-enable screenshot capability in restricted chats | +| Hide View Once Opened | Open view-once media without marking it as seen | +| Unlimited View-Once Replays | Replay view-once media as many times as you want | +| Permanent View Once Media (May cause some bugs ⚠️) | Prevent view-once media from disappearing | +| Keep Disappearing Messages | Stop ephemeral messages from deleting | +| Quick Toggle | Enable/disable all ghost options from inside Instagram with one tap | -⚠️ Install Instagram from [APKMirror](https://www.apkmirror.com/apk/instagram/instagram-instagram/), as the module may not fully support Google Play Store versions. +
---- +
+📥 Downloader — Save media directly from Instagram - **Install the Module** -- Download and install the **InstaEclipse APK**. You can find the latest release [here](https://github.com/ReSo7200/InstaEclipse/releases). +
-### ✅ Root Users (LSPosed) +| Feature | Description | +|---|---| +| Download Posts | Save single photos and carousel posts | +| Download Reels | Save reels to your device | +| Download Stories | Save stories before they disappear | +| Download Profile Pictures | Long-press a profile to save their picture | +| Custom Download Folder | Choose where files are saved | +| Username Subfolders | Organize downloads by username automatically | +
-1️⃣ **Enable the Module in LSPosed** -- Make sure you're using the latest **LSPosed fork by [JingMatrix](https://github.com/JingMatrix/LSPosed)**. -- Open **LSPosed Manager** and enable **InstaEclipse** for the **Instagram app**. +
+🎛️ Developer Options — Access Instagram's hidden internal settings -2️⃣ **Access the Features** -- Open **Instagram**, then **long-press the search icon** to access InstaEclipse settings. +
---- +- Unlock the full MetaConfig developer panel +- Import/Export your config as JSON +- Remove the "Build Expired" popup on older builds -### 🟡 Non-Root Users (LSPatch) +> **Note:** Use Beta or Alpha Instagram builds for best results. Stable builds apply obfuscation that makes some labels appear as numbers. -1️⃣ **Install LSPatch (JingMatrix Fork)** -- Download and install the **LSPatch fork by [JingMatrix](https://github.com/JingMatrix/LSPatch)**. +
-2️⃣ **Patch Instagram** -- Patch the **installed Instagram** or an **APK**. -- Use **Local Patch Mode**. -- Enable **"Inject loader dex"** in patch settings. -- Install the patched APK and log in to Instagram. +
+🛡️ Ad & Analytics Blocking — Browse without being tracked -3️⃣ **Enable the Module in LSPatch** -- Reopen **LSPatch**, go to the module list, and enable **InstaEclipse** for **Instagram**. +
-4️⃣ **Access the Features** -- Open **Instagram**, then **long-press the search icon** to access InstaEclipse settings. +- Block sponsored posts and ads from your feed +- Block Instagram's analytics and telemetry +- Disable tracking links in DMs and posts
-
+🧘 Distraction-Free Mode — Take back control of your attention -

📖 FAQ

+
-### ❓ Module not enabled/Features not working? -Disable and re-enable the module in LSPosed/LSPatch. -Force stop and restart Instagram. +- Disable Stories, Feed, Reels, Explore, or Comments independently +- **Extreme Mode** — permanently removes distractions until reinstall -### ❓ Why are some labels obfuscated or numbered? -This is due to obfuscation in **Stable** versions of Instagram. Use **Beta** or **Alpha** versions to avoid this. +> After enabling, force stop Instagram and clear its cache. -### ❓ Distraction-Free Mode enabled, but content still appears? -Force stop Instagram and **clear its cache** to apply the changes properly. +
+ +
+⚙️ Miscellaneous — Quality of life improvements + +
+ +| Feature | Description | +|---|---| +| Disable Story Auto-Swipe | Stop stories from flipping automatically | +| Disable Video Autoplay | Videos don't play until you tap them | +| Follower Toast | See if someone follows you back when you visit their profile | +| Copy Comment | Copy any comment text with one tap | +| View Story Mentions | See all @mentions in a story at once | +| Disable Discover People | Remove the "People you may know" section |
-

📂 Resources

+💾 Backup & Restore — Keep your settings safe -- 🐙 **GitHub Repository:** [Explore InstaEclipse](https://github.com/ReSo7200/InstaEclipse) -- 💬 **Support & Updates:** [Telegram Channel](https://t.me/InstaEclipse) -- ⚙️ **LSPosed - Fork By [JingMatrix](https://github.com/JingMatrix/)** [LSPosed](https://github.com/JingMatrix/LSPosed) +
+Export and restore all your InstaEclipse settings as a file — useful when switching devices or reinstalling.
-## 🎉 Contributors +--- -### 👑 Project Owner -- [ReSo7200](https://github.com/ReSo7200/) +## 📱 Instagram Compatibility -### 💡 Contributors -- [frknkrc44](https://github.com/frknkrc44) -- [BrianML](https://github.com/brianml31) -- [silvzr](https://github.com/silvzr) -- [oct888](https://github.com/oct888) -- [HalfManBear](https://github.com/halfmanbear) -- [ar5to](https://github.com/ar5to) +While InstaEclipse is built to stay compatible with new Instagram releases automatically, some features may be unstable on specific versions. If something stops working after an Instagram update, check the [Telegram channel](https://t.me/InstaEclipse) for status updates before opening an issue. -## 🙌 Special Thanks -- [xHookman](https://github.com/xHookman) -- **Amàzing World** -- **Bluepapilte (MyInsta Mod Owner)** [Telegram](https://t.me/instasmashrepo) -- **BdrcnAYYDIN** [Telegram](https://t.me/BdrcnAYYDIN) +| | | +|---|---| +| **Latest tested version** | [`425.0.0.0.0`](https://www.apkmirror.com/apk/instagram/instagram-instagram/instagram-425-0-0-0-0-release/) | +| **Recommended build type** | Beta or Alpha (from APKMirror) | +--- + +## 📲 Installation + +> ⚠️ **Use Instagram from [APKMirror](https://www.apkmirror.com/apk/instagram/instagram-instagram/)** — the Google Play version may not be fully supported. + +Download the latest InstaEclipse APK from [**Releases →**](https://github.com/ReSo7200/InstaEclipse/releases/latest) + +--- + +### ✅ Root — LSPosed + +> Requires [JingMatrix's LSPosed](https://github.com/JingMatrix/LSPosed/releases/latest) + +**1. Install InstaEclipse** +Download and install the InstaEclipse APK. -### 🌍 Translation Contributors -A heartfelt thank you to everyone who contributed to translating InstaEclipse into multiple languages. Your efforts help make the module accessible to users worldwide! 🌟 +**2. Enable the module** +Open **LSPosed Manager** → **Modules** → find **InstaEclipse** → enable it and scope it to **Instagram**. +**3. Restart Instagram** +Force stop Instagram, then reopen it. -## 🛠️ Powered By +**4. Open InstaEclipse** +Inside Instagram, **long-press the search icon** to open the InstaEclipse menu. -- [JingMatrix/LSPosed](https://github.com/JingMatrix/LSPosed), the foundation for module functionality. -- [LuckyPray/DexKit](https://github.com/LuckyPray/DexKit), enabling dynamic analysis for compatibility with new Instagram updates. +--- + +### 🟡 No Root — LSPatch + +> Requires [JingMatrix's LSPatch](https://github.com/JingMatrix/LSPatch/releases/latest) + +**1. Install InstaEclipse** +Download and install the InstaEclipse APK. + +**2. Install LSPatch** +Download and install LSPatch (JingMatrix fork). +**3. Patch Instagram** +- Open LSPatch → tap **+** → select the Instagram APK (or the installed app) +- Choose **Local Patch Mode** +- Enable **"Inject loader dex"** +- Tap **Start Patch** and wait -### 💡 Contributions -We welcome contributions from everyone! -- **Have an idea?** Open an issue or submit a feature request. -- **Found a bug?** Report it through our [GitHub Issues](https://github.com/ReSo7200/InstaEclipse/issues). -- **Want to help?** Submit a pull request to improve InstaEclipse. +**4. Install the patched APK** +Install the output APK and log in to Instagram. + +**5. Enable the module** +Reopen LSPatch → **Manage** → find Instagram → **Modules** → enable **InstaEclipse**. + +**6. Open InstaEclipse** +Inside Instagram, **long-press the search icon** to open the InstaEclipse menu. + +--- + +## ❓ FAQ + +**Module not working / features not applying?** +Disable and re-enable the module in LSPosed/LSPatch, then force stop and restart Instagram. + +**Developer options labels look like numbers?** +This is obfuscation from Instagram's Stable build. Switch to a Beta or Alpha version from APKMirror. + +**Distraction-Free enabled but content still shows?** +Force stop Instagram and clear its cache after enabling. + +**Not working on the Google Play version?** +Download Instagram from [APKMirror](https://www.apkmirror.com/apk/instagram/instagram-instagram/) instead. + +**Still stuck?** +Join the [Telegram group](https://t.me/instaEclipse_discussion) and ask — someone will help. + +--- + +## 🗺️ Using the Features + +Once InstaEclipse is installed and active, **long-press the search icon** inside Instagram to open the InstaEclipse menu. From there you can toggle any feature on or off without restarting. + +For guides on specific features, tips, and video walkthroughs: + +- 📢 **Announcements & updates** → [Telegram Channel](https://t.me/InstaEclipse) +- 💬 **Questions & community help** → [Telegram Discussion Group](https://t.me/instaEclipse_discussion) + +--- + +## 👥 Contributors + +
+ +### Project Owner + + + ReSo7200
+ ReSo7200 +
+ +

+ +### All Contributors + + + Contributors + + +Made with contrib.rocks + +
+ +**Translation Contributors** +A big thank you to everyone who helped translate InstaEclipse into multiple languages — you make this accessible to users around the world. + +
+ +--- + +## 🛠️ Built With + +- [JingMatrix/LSPosed](https://github.com/JingMatrix/LSPosed) — Xposed framework foundation +- [LuckyPray/DexKit](https://github.com/LuckyPray/DexKit) — Dynamic DEX analysis for Instagram compatibility + +--- + +## 🤝 Contributing + +Contributions are welcome — whether it's a bug report, feature request, translation, or pull request. + +- **Bug?** → [Open a bug report](https://github.com/ReSo7200/InstaEclipse/issues/new/choose) +- **Idea?** → [Submit a feature request](https://github.com/ReSo7200/InstaEclipse/issues/new/choose) +- **Code?** → Fork the repo and open a PR + +--- -> Every contribution, big or small, is highly valued. Thank you for helping us grow! +
+ Made with ❤️ by the InstaEclipse team
+ InstaEclipse is not affiliated with Meta or Instagram. See Disclaimer. +
diff --git a/app/build.gradle b/app/build.gradle index 7b821481..5f1e5807 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { applicationId "ps.reso.instaeclipse" minSdkVersion 28 targetSdk 36 - versionCode 12 - versionName '0.4.5' + versionCode 13 + versionName '0.5.0' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } @@ -43,6 +43,7 @@ dependencies { implementation libs.gson implementation libs.dexkit implementation 'com.google.android.material:material:1.12.0' + implementation "androidx.documentfile:documentfile:1.1.0" // External library for shared preferences implementation libs.fileprefs diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7969a81..f29560fe 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,14 @@ android:exported="true" android:theme="@style/Theme.InstaEclipse" /> + + + + + + + + + + diff --git a/app/src/main/java/ps/reso/instaeclipse/MainActivity.java b/app/src/main/java/ps/reso/instaeclipse/MainActivity.java index 336a7968..91769633 100644 --- a/app/src/main/java/ps/reso/instaeclipse/MainActivity.java +++ b/app/src/main/java/ps/reso/instaeclipse/MainActivity.java @@ -2,6 +2,7 @@ import android.annotation.SuppressLint; import android.os.Bundle; +import android.widget.FrameLayout; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; @@ -9,7 +10,9 @@ import androidx.fragment.app.Fragment; import com.google.android.material.bottomnavigation.BottomNavigationView; +import com.google.android.material.color.DynamicColors; +import ps.reso.instaeclipse.fragments.FeaturesFragment; import ps.reso.instaeclipse.fragments.HelpFragment; import ps.reso.instaeclipse.fragments.HomeFragment; import ps.reso.instaeclipse.utils.version.VersionCheckUtility; @@ -19,6 +22,8 @@ public class MainActivity extends AppCompatActivity { @SuppressLint("NonConstantResourceId") @Override protected void onCreate(Bundle savedInstanceState) { + DynamicColors.applyToActivityIfAvailable(this); + super.onCreate(savedInstanceState); VersionCheckUtility.checkForUpdates(this); @@ -27,9 +32,23 @@ protected void onCreate(Bundle savedInstanceState) { Toolbar toolbar = findViewById(R.id.top_app_bar); setSupportActionBar(toolbar); ActionBar actionBar = getSupportActionBar(); - actionBar.setDisplayShowTitleEnabled(false); + if (actionBar != null) { + actionBar.setDisplayShowTitleEnabled(false); + } BottomNavigationView bottomNavigation = findViewById(R.id.bottom_navigation); + FrameLayout fragmentContainer = findViewById(R.id.fragment_container); + + // On targetSdk 35+, edge-to-edge is enforced. The BottomNavigationView absorbs the + // system gesture inset via fitsSystemWindows, making its actual height larger than the + // fixed 82dp we had in XML. Sync the fragment container's bottom padding to match the + // nav bar's real height after each layout pass. + bottomNavigation.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or, ob) -> { + int navHeight = v.getHeight(); + if (fragmentContainer.getPaddingBottom() != navHeight) { + fragmentContainer.setPadding(0, 0, 0, navHeight); + } + }); // Load the HomeFragment by default if (savedInstanceState == null) { @@ -48,11 +67,12 @@ protected void onCreate(Bundle savedInstanceState) { if (item.getItemId() == R.id.nav_home) { selectedFragment = new HomeFragment(); + } else if (item.getItemId() == R.id.nav_features) { + selectedFragment = new FeaturesFragment(); } else if (item.getItemId() == R.id.nav_help) { selectedFragment = new HelpFragment(); } - if (selectedFragment != null) { getSupportFragmentManager() .beginTransaction() @@ -66,12 +86,9 @@ protected void onCreate(Bundle savedInstanceState) { @Override public void onBackPressed() { if (getSupportFragmentManager().getBackStackEntryCount() > 0) { - // If there are fragments in the back stack, pop the stack getSupportFragmentManager().popBackStack(); } else { - // Otherwise, handle the default back button behavior super.onBackPressed(); } } - } diff --git a/app/src/main/java/ps/reso/instaeclipse/Xposed/Module.java b/app/src/main/java/ps/reso/instaeclipse/Xposed/Module.java index 2d31f5c6..a75babfa 100644 --- a/app/src/main/java/ps/reso/instaeclipse/Xposed/Module.java +++ b/app/src/main/java/ps/reso/instaeclipse/Xposed/Module.java @@ -3,35 +3,56 @@ import static de.robv.android.xposed.XposedHelpers.findAndHookMethod; import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; import android.os.Build; +import android.os.Bundle; + +import androidx.core.content.ContextCompat; import org.luckypray.dexkit.DexKitBridge; -import java.util.Arrays; import java.util.List; +import java.util.Map; import de.robv.android.xposed.IXposedHookLoadPackage; import de.robv.android.xposed.IXposedHookZygoteInit; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XC_MethodReplacement; +import de.robv.android.xposed.XSharedPreferences; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage; import ps.reso.instaeclipse.mods.ads.AdBlocker; import ps.reso.instaeclipse.mods.ads.TrackingLinkDisable; -import ps.reso.instaeclipse.mods.devops.DevOptionsEnable; -import ps.reso.instaeclipse.mods.ghost.ScreenshotDetection; -import ps.reso.instaeclipse.mods.ghost.SeenState; -import ps.reso.instaeclipse.mods.ghost.StorySeen; -import ps.reso.instaeclipse.mods.ghost.TypingStatus; -import ps.reso.instaeclipse.mods.ghost.ViewOnce; -import ps.reso.instaeclipse.mods.misc.AutoPlayDisable; -import ps.reso.instaeclipse.mods.misc.FollowerIndicator; -import ps.reso.instaeclipse.mods.misc.StoryFlipping; -import ps.reso.instaeclipse.mods.network.Interceptor; +import ps.reso.instaeclipse.mods.devops.BuildExpiredPopupHook; +import ps.reso.instaeclipse.mods.devops.DevOptionsUnlockHook; +import ps.reso.instaeclipse.mods.ghost.GhostChannelMarkAsReadHook; +import ps.reso.instaeclipse.mods.ghost.GhostDMMarkAsReadHook; +import ps.reso.instaeclipse.mods.ghost.GhostDMSeenHook; +import ps.reso.instaeclipse.mods.ghost.GhostEphemeralKeepHook; +import ps.reso.instaeclipse.mods.ghost.GhostPermanentViewHook; +import ps.reso.instaeclipse.mods.ghost.GhostReplayLimitHook; +import ps.reso.instaeclipse.mods.ghost.GhostScreenshotDetectionHook; +import ps.reso.instaeclipse.mods.ghost.GhostStorySeenHook; +import ps.reso.instaeclipse.mods.ghost.GhostTypingIndicatorHook; +import ps.reso.instaeclipse.mods.ghost.GhostViewOnceHook; +import ps.reso.instaeclipse.mods.ghost.ScreenshotPermissionHook; +import ps.reso.instaeclipse.mods.media.FeedVideoDownloadHook; +import ps.reso.instaeclipse.mods.media.PostDownloadContextMenuHook; +import ps.reso.instaeclipse.mods.media.ProfilePicDownloadHook; +import ps.reso.instaeclipse.mods.media.ReelDownloadHook; +import ps.reso.instaeclipse.mods.media.StoryDownloadHook; +import ps.reso.instaeclipse.mods.misc.CommentCopyHook; +import ps.reso.instaeclipse.mods.misc.DisableStoryFlippingHook; +import ps.reso.instaeclipse.mods.misc.DisableVideoAutoPlayHook; +import ps.reso.instaeclipse.mods.misc.StoryMentionHook; +import ps.reso.instaeclipse.mods.network.IGNetworkInterceptor; import ps.reso.instaeclipse.mods.ui.UIHookManager; import ps.reso.instaeclipse.utils.core.CommonUtils; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.core.SettingsManager; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureManager; @@ -39,12 +60,11 @@ @SuppressLint("UnsafeDynamicallyLoadedCode") public class Module implements IXposedHookLoadPackage, IXposedHookZygoteInit { - // List of supported Instagram package names - private static final List SUPPORTED_PACKAGES = Arrays.asList(CommonUtils.IG_PACKAGE_NAME, // Original package name - "com.instagram.android", "com.instagold.android", "com.instaflux.app", "com.myinsta.android", "cc.honista.app", "com.instaprime.android", "com.instafel.android", "com.instadm.android", "com.dfistagram.android", "com.Instander.android", "com.aero.instagram", "com.instapro.android", "com.instaflow.android", "com.instagram1.android", "com.instagram2.android", "com.instagramclone.android", "com.instaclone.android"); + // List of supported Instagram package names (maintained in CommonUtils) + private static final List SUPPORTED_PACKAGES = CommonUtils.SUPPORTED_PACKAGES; public static DexKitBridge dexKitBridge; public static ClassLoader hostClassLoader; - private static String moduleSourceDir; + public static String moduleSourceDir; private static String moduleLibDir; // for dev usage @@ -56,25 +76,18 @@ public static void showToast(final String text) { @Override public void initZygote(StartupParam startupParam) { - XposedBridge.log("(InstaEclipse): Zygote initialized."); - - // Save the module's APK path moduleSourceDir = startupParam.modulePath; - // Detect ABI correctly - String abi = Build.SUPPORTED_ABIS[0]; // Primary ABI + String abi = Build.SUPPORTED_ABIS[0]; String abiFolder; - if (abi.equalsIgnoreCase("arm64-v8a")) abiFolder = "arm64"; else if (abi.equalsIgnoreCase("armeabi-v7a") || abi.equalsIgnoreCase("armeabi") || abi.equalsIgnoreCase("armv8i")) abiFolder = "arm"; else if (abi.equalsIgnoreCase("x86")) abiFolder = "x86"; else if (abi.equalsIgnoreCase("x86_64")) abiFolder = "x86_64"; - else abiFolder = abi; // fallback just in case + else abiFolder = abi; moduleLibDir = moduleSourceDir.substring(0, moduleSourceDir.lastIndexOf("/")) + "/lib/" + abiFolder; - - XposedBridge.log("(InstaEclipse) Module paths initialized:" + "\nSourceDir: " + moduleSourceDir + "\nLibDir: " + moduleLibDir); } @Override @@ -82,21 +95,12 @@ public void handleLoadPackage(final XC_LoadPackage.LoadPackageParam lpparam) { // Ensure preferences are loaded - XposedBridge.log("(InstaEclipse): Loaded package: " + lpparam.packageName); - // Hook into your module if (lpparam.packageName.equals(CommonUtils.MY_PACKAGE_NAME)) { try { - if (dexKitBridge == null) { - // Load the .so file from your module System.load(moduleLibDir + "/libdexkit.so"); - XposedBridge.log("libdexkit.so loaded successfully."); - - // Initialize DexKitBridge with your module's APK (for module-specific tasks, if needed) dexKitBridge = DexKitBridge.create(moduleSourceDir); - - XposedBridge.log("DexKitBridge initialized for InstaEclipse."); } // Hook your module @@ -145,61 +149,118 @@ private void hookInstagram(XC_LoadPackage.LoadPackageParam lpparam) { try { + XposedHelpers.findAndHookMethod("android.app.Application", lpparam.classLoader, "attach", Context.class, new XC_MethodHook() { @Override - protected void afterHookedMethod(MethodHookParam param) { + protected void beforeHookedMethod(MethodHookParam param) { + // Install CommentCopyButtonHook BEFORE Instagram's Application.attach() runs + // so we catch any ViewBinding pre-inflation that happens during attach() + Context context = (Context) param.args[0]; + SettingsManager.init(context); + SettingsManager.loadAllFlags(context); - XposedBridge.log("InstaEclipse: Settings loaded via Application.attach for " + lpparam.packageName); + // Init DexKit cache — checks IG version to decide if saved descriptors are valid. + // Must run before any hook that calls DexKitCache.isCacheValid(). + try { + android.content.pm.PackageInfo pi = + context.getPackageManager().getPackageInfo(context.getPackageName(), 0); + long vc = pi.getLongVersionCode(); + DexKitCache.init(context, String.valueOf(vc)); + } catch (Throwable e) { + XposedBridge.log("(DexKitCache) ❌ init failed: " + e.getMessage()); + } + } + + @Override + protected void afterHookedMethod(MethodHookParam param) { // Setup context, preferences Context context = (Context) param.args[0]; SettingsManager.init(context); SettingsManager.loadAllFlags(context); + + // Pull downloader path from companion app's cache so it's available even + // when Instagram was started without ever receiving the sync broadcast. + try { + XSharedPreferences cp = new XSharedPreferences(CommonUtils.MY_PACKAGE_NAME, "instaeclipse_cache"); + cp.reload(); + String path = cp.getString("downloaderCustomPath", ""); + String uri = cp.getString("downloaderCustomUri", ""); + if (!path.isEmpty()) FeatureFlags.downloaderCustomPath = path; + if (!uri.isEmpty()) FeatureFlags.downloaderCustomUri = uri; + } catch (Throwable ignored) { + } + FeatureManager.refreshFeatureStatus(); // Update internal feature states + // Activate the LSPosed Sync Bridge to listen to FeaturesFragment updates + registerSyncReceiver(context); + + try { + UIHookManager.registerConfigImportReceiver(context); + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | ImportReceiver): ❌ " + e.getMessage()); + } + try { + UIHookManager.registerSettingsRestoreReceiver(context); + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | RestoreReceiver): ❌ " + e.getMessage()); + } UIHookManager instagramUI = new UIHookManager(); instagramUI.mainActivity(hostClassLoader); - XposedBridge.log("(InstaEclipse): " + lpparam.packageName + " package detected. Starting feature hooks..."); - - Interceptor interceptor = new Interceptor(); + IGNetworkInterceptor interceptor = new IGNetworkInterceptor(); // --- Feature Hooks --- // Developer Options try { - new DevOptionsEnable().handleDevOptions(dexKitBridge); + new DevOptionsUnlockHook().handleDevOptions(dexKitBridge); } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | DevOptions): ❌ Failed to hook"); } // Ghost Mode try { - new SeenState().handleSeenBlock(dexKitBridge); // DM Seen + new GhostDMSeenHook().handleSeenBlock(dexKitBridge); // DM Seen + new GhostDMMarkAsReadHook(moduleSourceDir).install(lpparam.classLoader); // Mark as Read Button + new GhostChannelMarkAsReadHook().install(lpparam.classLoader); // Channel Mark as Read Button } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | GhostSeen): ❌ Failed to hook"); } try { - new TypingStatus().handleTypingBlock(dexKitBridge); // DM Typing + new GhostTypingIndicatorHook().handleTypingBlock(dexKitBridge); // DM Typing } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | GhostTyping): ❌ Failed to hook"); } try { - new ScreenshotDetection().handleScreenshotBlock(dexKitBridge); // Screenshot + new GhostScreenshotDetectionHook().handleScreenshotBlock(dexKitBridge); // Screenshot } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | GhostScreenshot): ❌ Failed to hook"); } try { - new ViewOnce().handleViewOnceBlock(dexKitBridge); // View Once + new ScreenshotPermissionHook().install(lpparam.classLoader); // Allow Screenshots + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | ScreenshotPermission): ❌ Failed to hook"); + } + + try { + new GhostViewOnceHook().handleViewOnceBlock(dexKitBridge); // View Once } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | GhostViewOnce): ❌ Failed to hook"); } try { - new StorySeen().handleStorySeenBlock(dexKitBridge); // Story Seen + new GhostReplayLimitHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | UnlimitedReplays): ❌ Failed to hook"); + } + + try { + new GhostStorySeenHook().handleStorySeenBlock(dexKitBridge); // Story Seen } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | GhostStorySeen): ❌ Failed to hook"); } @@ -220,33 +281,86 @@ protected void afterHookedMethod(MethodHookParam param) { // Miscellaneous try { - new StoryFlipping().handleStoryFlippingDisable(dexKitBridge); // Story Flipping + new DisableStoryFlippingHook().handleStoryFlippingDisable(dexKitBridge); // Story Flipping } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | StoryFlipping): ❌ Failed to hook"); } + // Story Mentions + try { + new StoryMentionHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | StoryMentions): ❌ Failed to hook"); + } + + // Comment Copy + try { + new CommentCopyHook().install(lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ Failed to hook"); + } + try { - new AutoPlayDisable().handleAutoPlayDisable(dexKitBridge); // Video Autoplay + new DisableVideoAutoPlayHook().handleAutoPlayDisable(dexKitBridge); // Video Autoplay } catch (Throwable ignored) { XposedBridge.log("(InstaEclipse | AutoPlayDisable): ❌ Failed to hook"); } + // Build Expired Popup + try { + new BuildExpiredPopupHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | BuildExpired): ❌ Failed to hook"); + } + + // Media Download (feed) try { - FollowerIndicator followerIndicator = new FollowerIndicator(); - FollowerIndicator.FollowMethodResult result = followerIndicator.findFollowerStatusMethod(Module.dexKitBridge); + new FeedVideoDownloadHook().install(lpparam.classLoader); + FeedVideoDownloadHook.installVideoUrlCaptureHook(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | MediaDownload): ❌ Failed to hook"); + } - if (result != null && FeatureFlags.showFollowerToast) { + // Post Download — three-dots menu (replaces floating button + long-press) + try { + new PostDownloadContextMenuHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | PostDownload): ❌ Failed to hook"); + } - String userIdClass = followerIndicator.findUserIdClassIfNeeded(Module.dexKitBridge, result.userClassName); + // Keep Ephemeral Messages + try { + new GhostEphemeralKeepHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | EphemeralHook): ❌ Failed to hook"); + } - followerIndicator.checkFollow(hostClassLoader, result.methodName, result.userClassName, userIdClass); + // Permanent View Mode (view-once / view-twice → permanent) + try { + new GhostPermanentViewHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | ViewOnceMedia): ❌ Failed to hook"); + } - } else { - XposedBridge.log("(InstaEclipse | FollowerToast): ❌ Method not found"); - } - } catch (Throwable e) { + // Story Download + try { + new StoryDownloadHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | StoryDownload): ❌ Failed to hook"); + } - XposedBridge.log("(InstaEclipse | FollowerToast): ❌ Failed to hook + " + e); + // Reel Download + try { + new ReelDownloadHook().install(dexKitBridge, lpparam.classLoader); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | ReelDownload): ❌ Failed to hook"); + } + + // Profile Picture Download + try { + ProfilePicDownloadHook.install(); + } catch (Throwable ignored) { + XposedBridge.log("(InstaEclipse | ProfileDownload): ❌ Failed to hook"); } // Network Interceptor @@ -264,4 +378,116 @@ protected void afterHookedMethod(MethodHookParam param) { XposedBridge.log("(InstaEclipse): Failed to hook " + lpparam.packageName + ": " + e.getMessage()); } } + + /** + * Injects a dynamic receiver into Instagram to listen for settings changes + * sent from the InstaEclipse companion app (FeaturesFragment staging system). + */ + private void registerSyncReceiver(Context context) { + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + String action = intent.getAction(); + if ("ps.reso.instaeclipse.ACTION_UPDATE_PREF".equals(action)) { + String key = intent.getStringExtra("key"); + boolean value = intent.getBooleanExtra("value", false); + + XposedBridge.log("(InstaEclipse) Sync: Updating " + key + " to " + value); + + android.content.SharedPreferences prefs = ctx.getSharedPreferences("instaeclipse_prefs", Context.MODE_PRIVATE); + prefs.edit().putBoolean(key, value).apply(); + + SettingsManager.loadAllFlags(ctx); + FeatureManager.refreshFeatureStatus(); + + } else if ("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING".equals(action)) { + String key = intent.getStringExtra("key"); + String value = intent.getStringExtra("value"); + + XposedBridge.log("(InstaEclipse) Sync: Updating string pref " + key); + + android.content.SharedPreferences prefs = ctx.getSharedPreferences("instaeclipse_prefs", Context.MODE_PRIVATE); + prefs.edit().putString(key, value).apply(); + + SettingsManager.loadAllFlags(ctx); + + } else if ("ps.reso.instaeclipse.ACTION_REQUEST_PREFS".equals(action)) { + XposedBridge.log("(InstaEclipse) Sync: Companion app requested current preferences."); + + android.content.SharedPreferences prefs = ctx.getSharedPreferences("instaeclipse_prefs", Context.MODE_PRIVATE); + Intent reply = new Intent("ps.reso.instaeclipse.ACTION_SEND_PREFS"); + reply.setPackage("ps.reso.instaeclipse"); + + Bundle bundle = new Bundle(); + for (Map.Entry entry : prefs.getAll().entrySet()) { + if (entry.getValue() instanceof Boolean) { + bundle.putBoolean(entry.getKey(), (Boolean) entry.getValue()); + } else if (entry.getValue() instanceof String) { + bundle.putString(entry.getKey(), (String) entry.getValue()); + } + } + reply.putExtras(bundle); + ctx.sendBroadcast(reply); + + } else if ("ps.reso.instaeclipse.ACTION_EXPORT_CONFIG".equals(action)) { + XposedBridge.log("(InstaEclipse) Sync: Companion app requested Dev Config export."); + try { + java.io.File source = new java.io.File(ctx.getFilesDir(), "mobileconfig/mc_overrides.json"); + if (!source.exists()) { + XposedBridge.log("(InstaEclipse) Export: mc_overrides.json not found."); + Intent reply = new Intent("ps.reso.instaeclipse.ACTION_SEND_CONFIG"); + reply.setPackage("ps.reso.instaeclipse"); + reply.putExtra("error", "mc_overrides.json not found."); + ctx.sendBroadcast(reply); + return; + } + StringBuilder sb = new StringBuilder(); + try (java.io.BufferedReader reader = new java.io.BufferedReader(new java.io.FileReader(source))) { + String line; + while ((line = reader.readLine()) != null) sb.append(line).append("\n"); + } + Intent reply = new Intent("ps.reso.instaeclipse.ACTION_SEND_CONFIG"); + reply.setPackage("ps.reso.instaeclipse"); + reply.putExtra("json_content", sb.toString().trim()); + ctx.sendBroadcast(reply); + XposedBridge.log("(InstaEclipse) Export: config reply sent to companion."); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse) Export: failed: " + e.getMessage()); + } + + } else if ("ps.reso.instaeclipse.ACTION_BACKUP_SETTINGS".equals(action)) { + XposedBridge.log("(InstaEclipse) Sync: Companion app requested Settings backup."); + try { + String json = ps.reso.instaeclipse.utils.backup.SettingsBackupManager.toJson(); + Intent exportIntent = new Intent(); + exportIntent.setComponent(new android.content.ComponentName("ps.reso.instaeclipse", "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); + exportIntent.putExtra("json_content", json); + exportIntent.putExtra("file_name", "instaeclipse_settings.json"); + exportIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(exportIntent); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse) Failed to create backup: " + e.getMessage()); + } + } + } + }; + + IntentFilter filter = new IntentFilter(); + filter.addAction("ps.reso.instaeclipse.ACTION_UPDATE_PREF"); + filter.addAction("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING"); + filter.addAction("ps.reso.instaeclipse.ACTION_REQUEST_PREFS"); + filter.addAction("ps.reso.instaeclipse.ACTION_EXPORT_CONFIG"); + filter.addAction("ps.reso.instaeclipse.ACTION_BACKUP_SETTINGS"); + + if (Build.VERSION.SDK_INT >= 33) { + context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED); + } else { + // On API < 33 there are no EXPORTED/NOT_EXPORTED flags. + // ContextCompat.RECEIVER_NOT_EXPORTED injects a custom permission that the + // companion app doesn't hold, silently blocking its broadcasts from reaching + // this receiver. Use the plain two-arg overload instead so the companion app + // can send ACTION_UPDATE_PREF_STRING and friends without restriction. + context.registerReceiver(receiver, filter); + } + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/fragments/FeaturesFragment.java b/app/src/main/java/ps/reso/instaeclipse/fragments/FeaturesFragment.java new file mode 100644 index 00000000..1ee394ad --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/fragments/FeaturesFragment.java @@ -0,0 +1,1002 @@ +package ps.reso.instaeclipse.fragments; + +import android.app.Activity; +import android.app.AlertDialog; +import android.content.BroadcastReceiver; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.transition.AutoTransition; +import android.transition.TransitionManager; +import android.util.TypedValue; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.DecelerateInterpolator; +import android.widget.ImageButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.card.MaterialCardView; +import com.google.android.material.color.MaterialColors; +import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.shape.ShapeAppearanceModel; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Scanner; + +import ps.reso.instaeclipse.R; + +public class FeaturesFragment extends Fragment { + + private SharedPreferences localCache; + private View fragmentView; + private RecyclerView recyclerFeatures; + private FeatureAdapter adapter; + private TextView tvHeaderTitle; + private ImageButton btnBack; + private ExtendedFloatingActionButton fabSave; + private ActivityResultLauncher dirPickerLauncher; + private ActivityResultLauncher restoreFileLauncher; + private ActivityResultLauncher notifPermLauncher; + + private String currentMenu = "main"; + + // STAGING SYSTEM: Holds changes before applying + private final Map stagedChanges = new HashMap<>(); + + private final BroadcastReceiver prefsReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if ("ps.reso.instaeclipse.ACTION_SEND_PREFS".equals(intent.getAction())) { + Bundle bundle = intent.getExtras(); + if (bundle != null) { + // If we just set the path locally (via dirPickerLauncher), the incoming + // reply from Instagram may still carry the old value — skip overwriting. + boolean suppressPath = localCache.getBoolean("pathJustSetLocally", false); + SharedPreferences.Editor editor = localCache.edit(); + if (suppressPath) editor.remove("pathJustSetLocally"); + for (String key : bundle.keySet()) { + Object value = bundle.get(key); + if (value instanceof Boolean) { + editor.putBoolean(key, (Boolean) value); + } else if (value instanceof String) { + if (suppressPath && ("downloaderCustomPath".equals(key) || "downloaderCustomUri".equals(key))) { + continue; + } + editor.putString(key, (String) value); + } + } + editor.apply(); + // Rebuild downloader menu so the folder title reflects the latest value + if ("downloader".equals(currentMenu)) { + loadDownloaderMenu(); + } else if (adapter != null) { + adapter.notifyDataSetChanged(); + } + } + } + } + }; + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + restoreFileLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocument(), uri -> { + if (uri == null) return; + try (InputStream is = requireContext().getContentResolver().openInputStream(uri); + Scanner scanner = new Scanner(is, "UTF-8").useDelimiter("\\A")) { + String json = scanner.hasNext() ? scanner.next() : ""; + JSONObject root = new JSONObject(json); + JSONObject s = root.has("settings") ? root.getJSONObject("settings") : root; + SharedPreferences.Editor editor = localCache.edit(); + Iterator keys = s.keys(); + while (keys.hasNext()) { + String key = keys.next(); + Object val = s.get(key); + if (val instanceof Boolean) { + editor.putBoolean(key, (Boolean) val); + Intent intent = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF"); + intent.putExtra("key", key); + intent.putExtra("value", (boolean) (Boolean) val); + requireContext().sendBroadcast(intent); + } + } + editor.apply(); + if (adapter != null) adapter.notifyDataSetChanged(); + Toast.makeText(requireContext(), getString(R.string.ig_toast_settings_restored), Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Toast.makeText(requireContext(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + + notifPermLauncher = registerForActivityResult( + new ActivityResultContracts.RequestPermission(), granted -> {}); + + dirPickerLauncher = registerForActivityResult(new ActivityResultContracts.OpenDocumentTree(), uri -> { + if (uri != null) { + final int takeFlags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + requireContext().getContentResolver().takePersistableUriPermission(uri, takeFlags); + + String uriString = uri.toString(); + String path = uri.getPath(); + + if (path != null) { + path = path.replace("/tree/primary:", "/storage/emulated/0/"); + path = path.replace("/document/primary:", "/storage/emulated/0/"); + } else { + path = uriString; + } + + // Set flag + save new path atomically so the incoming ACTION_SEND_PREFS + // reply (triggered by onResume's ACTION_REQUEST_PREFS) doesn't overwrite us. + // commit() (not apply()) ensures the XML is flushed to disk before we make it + // world-readable so the module's XSharedPreferences can pick it up on cold start. + SharedPreferences.Editor editor = localCache.edit(); + editor.putBoolean("pathJustSetLocally", true); + editor.putString("downloaderCustomUri", uriString); + editor.putString("downloaderCustomPath", path); + editor.commit(); + makeLocalCacheWorldReadable(); + + Intent intentUri = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING"); + intentUri.putExtra("key", "downloaderCustomUri"); + intentUri.putExtra("value", uriString); + requireContext().sendBroadcast(intentUri); + + Intent intentPath = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING"); + intentPath.putExtra("key", "downloaderCustomPath"); + intentPath.putExtra("value", path); + requireContext().sendBroadcast(intentPath); + + Toast.makeText(requireContext(), getString(R.string.ig_toast_download_folder_updated), Toast.LENGTH_SHORT).show(); + + if ("downloader".equals(currentMenu)) { + loadDownloaderMenu(); + } + } + }); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + fragmentView = inflater.inflate(R.layout.fragment_features, container, false); + localCache = requireContext().getSharedPreferences("instaeclipse_cache", Context.MODE_PRIVATE); + + // Request POST_NOTIFICATIONS permission (required API 33+ for download progress notifications) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(requireContext(), + android.Manifest.permission.POST_NOTIFICATIONS) + != android.content.pm.PackageManager.PERMISSION_GRANTED) { + notifPermLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS); + } + } + + recyclerFeatures = fragmentView.findViewById(R.id.recycler_features); + recyclerFeatures.setLayoutManager(new LinearLayoutManager(requireContext())); + // Disable change animations so conditional enabled/alpha updates are instant, not crossfaded + recyclerFeatures.setItemAnimator(null); + adapter = new FeatureAdapter(); + recyclerFeatures.setAdapter(adapter); + + tvHeaderTitle = fragmentView.findViewById(R.id.tv_header_title); + btnBack = fragmentView.findViewById(R.id.btn_back); + fabSave = fragmentView.findViewById(R.id.fab_save); + + btnBack.setOnClickListener(v -> loadMainMenu()); + + requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(), new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (!"main".equals(currentMenu)) { + loadMainMenu(); + } else { + setEnabled(false); + requireActivity().getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + + fabSave.setOnClickListener(v -> commitStagedChanges()); + + loadMainMenu(); + + return fragmentView; + } + + // ========================================================= + // MENU DATA MODEL & ADAPTER + // ========================================================= + + public static class FeatureItem { + public static final int TYPE_HEADER = 0; + public static final int TYPE_SWITCH = 1; + public static final int TYPE_CLICKABLE = 2; + public static final int TYPE_MASTER_SWITCH = 3; + public static final int TYPE_SPACER = 4; + + public int type; + public String title; + public String prefKey; + public Runnable onClick; + public int textColor; + public boolean isExtreme; + public List childKeys; + /** When this switch is turned ON, also stage this key as true (parent dependency). */ + public String dependsOn; + /** When this switch is turned OFF, also stage this key as false (child cascade). */ + public String cascadeOffKey; + /** Switch is only enabled when at least one key in this list is currently true. */ + public List requiresAnyOf; + /** Switch is locked (disabled) when this key is currently true. */ + public String disabledWhenTrue; + + public int segmentPosition; + public int segmentSize; + } + + static class HeaderViewHolder extends RecyclerView.ViewHolder { + TextView tvHeader; + HeaderViewHolder(View v) { + super(v); + tvHeader = v.findViewById(R.id.tv_header); + } + } + + static class ItemViewHolder extends RecyclerView.ViewHolder { + TextView tvTitle; + MaterialSwitch swToggle; + MaterialCardView cardView; + ItemViewHolder(View v) { + super(v); + tvTitle = v.findViewById(R.id.tv_title); + swToggle = v.findViewById(R.id.sw_toggle); + cardView = (MaterialCardView) v; + } + } + + private class FeatureAdapter extends RecyclerView.Adapter { + List items = new ArrayList<>(); + + public void setItems(List items) { + this.items = items; + notifyDataSetChanged(); + } + + @Override + public int getItemViewType(int position) { + int type = items.get(position).type; + if (type == FeatureItem.TYPE_HEADER) return 0; + if (type == FeatureItem.TYPE_SPACER) return 2; + return 1; + } + + @NonNull + @Override + public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + if (viewType == 0) { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_feature_header, parent, false); + return new HeaderViewHolder(v); + } else if (viewType == 2) { + // Spacer: a plain transparent view with fixed height + View v = new View(parent.getContext()); + v.setLayoutParams(new RecyclerView.LayoutParams( + RecyclerView.LayoutParams.MATCH_PARENT, + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 12f, parent.getResources().getDisplayMetrics()) + )); + return new RecyclerView.ViewHolder(v) {}; + } else { + View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_feature, parent, false); + return new ItemViewHolder(v); + } + } + + @Override + public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { + FeatureItem item = items.get(position); + + if (item.type == FeatureItem.TYPE_SPACER) return; + + if (holder instanceof HeaderViewHolder) { + ((HeaderViewHolder) holder).tvHeader.setText(item.title); + } else if (holder instanceof ItemViewHolder) { + ItemViewHolder itemHolder = (ItemViewHolder) holder; + + itemHolder.tvTitle.setText(item.title); + + // Master switches get a tinted card + bold text to stand out from regular items + boolean isMaster = item.type == FeatureItem.TYPE_MASTER_SWITCH; + if (isMaster) { + int bg = MaterialColors.getColor(itemHolder.itemView, com.google.android.material.R.attr.colorSecondaryContainer); + int fg = MaterialColors.getColor(itemHolder.itemView, com.google.android.material.R.attr.colorOnSecondaryContainer); + itemHolder.cardView.setCardBackgroundColor(bg); + itemHolder.tvTitle.setTextColor(fg); + itemHolder.tvTitle.setTypeface(null, android.graphics.Typeface.BOLD); + } else { + int bg = MaterialColors.getColor(itemHolder.itemView, com.google.android.material.R.attr.colorSurfaceContainerLow); + int defaultTextColor = MaterialColors.getColor(itemHolder.itemView, com.google.android.material.R.attr.colorOnSurface); + itemHolder.cardView.setCardBackgroundColor(bg); + itemHolder.tvTitle.setTextColor(item.textColor != 0 ? item.textColor : defaultTextColor); + itemHolder.tvTitle.setTypeface(null, android.graphics.Typeface.NORMAL); + } + + float largeRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 24f, getResources().getDisplayMetrics()); + float smallRadius = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 4f, getResources().getDisplayMetrics()); + + ShapeAppearanceModel.Builder shapeBuilder = itemHolder.cardView.getShapeAppearanceModel().toBuilder(); + + if (item.segmentSize == 1) { + shapeBuilder.setAllCornerSizes(largeRadius); + } else if (item.segmentPosition == 0) { + shapeBuilder.setTopLeftCornerSize(largeRadius).setTopRightCornerSize(largeRadius) + .setBottomLeftCornerSize(smallRadius).setBottomRightCornerSize(smallRadius); + } else if (item.segmentPosition == item.segmentSize - 1) { + shapeBuilder.setTopLeftCornerSize(smallRadius).setTopRightCornerSize(smallRadius) + .setBottomLeftCornerSize(largeRadius).setBottomRightCornerSize(largeRadius); + } else { + shapeBuilder.setAllCornerSizes(smallRadius); + } + itemHolder.cardView.setShapeAppearanceModel(shapeBuilder.build()); + + // Compute enabled state from conditions + boolean switchEnabled = true; + if (item.requiresAnyOf != null) { + switchEnabled = false; + for (String k : item.requiresAnyOf) { + if (getCurrentState(k)) { switchEnabled = true; break; } + } + } + if (item.disabledWhenTrue != null && getCurrentState(item.disabledWhenTrue)) { + switchEnabled = false; + } + itemHolder.itemView.setEnabled(switchEnabled); + itemHolder.swToggle.setEnabled(switchEnabled); + itemHolder.itemView.setAlpha(switchEnabled ? 1f : 0.4f); + + itemHolder.swToggle.setOnCheckedChangeListener(null); + itemHolder.itemView.setOnClickListener(null); + + if (item.type == FeatureItem.TYPE_CLICKABLE) { + itemHolder.swToggle.setVisibility(View.GONE); + itemHolder.itemView.setOnClickListener(v -> { + if (item.onClick != null) item.onClick.run(); + }); + } else { + itemHolder.swToggle.setVisibility(View.VISIBLE); + itemHolder.itemView.setOnClickListener(v -> itemHolder.swToggle.toggle()); + + boolean isChecked = false; + if (item.type == FeatureItem.TYPE_MASTER_SWITCH) { + isChecked = true; + for (String key : item.childKeys) { + if (!getCurrentState(key)) { + isChecked = false; + break; + } + } + } else if (item.prefKey != null) { + isChecked = getCurrentState(item.prefKey); + } + itemHolder.swToggle.setChecked(isChecked); + + itemHolder.swToggle.setOnCheckedChangeListener((btn, checked) -> { + if (item.isExtreme && checked) { + new AlertDialog.Builder(itemHolder.itemView.getContext()) + .setTitle(getString(R.string.ig_dialog_distraction_extreme_title)) + .setMessage(getString(R.string.ig_dialog_distraction_extreme_message)) + .setPositiveButton(getString(R.string.ig_dialog_yes), (dialog, which) -> { + stageChange(item.prefKey, true); + stageChange("isDistractionFree", true); + updateMasterSwitches(); + refreshConditionalItems(); + }) + .setNegativeButton(getString(R.string.ig_dialog_cancel), (dialog, which) -> { + itemHolder.swToggle.setChecked(false); + }) + .show(); + return; + } + + if (item.type == FeatureItem.TYPE_MASTER_SWITCH) { + for (String key : item.childKeys) { + stageChange(key, checked); + } + for (int i = 0; i < items.size(); i++) { + if (items.get(i).type == FeatureItem.TYPE_SWITCH) { + notifyItemChanged(i); + } + } + refreshConditionalItems(); + } else if (item.prefKey != null) { + stageChange(item.prefKey, checked); + // Auto-enable parent dependency (e.g. disableReels when disableReelsExceptDM is on) + if (checked && item.dependsOn != null) { + stageChange(item.dependsOn, true); + refreshRowByKey(item.dependsOn); + } + // Auto-disable child when parent is turned off (e.g. disableReelsExceptDM when disableReels is off) + if (!checked && item.cascadeOffKey != null) { + stageChange(item.cascadeOffKey, false); + refreshRowByKey(item.cascadeOffKey); + } + updateMasterSwitches(); + refreshConditionalItems(); + } + }); + } + } + } + + private void refreshRowByKey(String prefKey) { + for (int i = 0; i < items.size(); i++) { + if (prefKey.equals(items.get(i).prefKey)) { + notifyItemChanged(i); + break; + } + } + } + + /** Refreshes any item whose enabled state depends on runtime conditions. */ + void refreshConditionalItems() { + for (int i = 0; i < items.size(); i++) { + FeatureItem fi = items.get(i); + if (fi.requiresAnyOf != null || fi.disabledWhenTrue != null) { + notifyItemChanged(i); + } + } + } + + private void updateMasterSwitches() { + for (int i = 0; i < items.size(); i++) { + if (items.get(i).type == FeatureItem.TYPE_MASTER_SWITCH) { + notifyItemChanged(i); + } + } + } + + @Override + public int getItemCount() { + return items.size(); + } + } + + // ========================================================= + // MENU BUILDING HELPERS + // ========================================================= + + private void showMenu(String title, List definitions) { + ViewGroup headerLayout = fragmentView.findViewById(R.id.header_layout); + TransitionManager.beginDelayedTransition(headerLayout, new AutoTransition().setDuration(200)); + + tvHeaderTitle.setText(title); + btnBack.setVisibility(title.equals(getString(R.string.features)) ? View.GONE : View.VISIBLE); + + List displayList = new ArrayList<>(); + for (int di = 0; di < definitions.size(); di++) { + Object def = definitions.get(di); + if (def instanceof String) { + FeatureItem header = new FeatureItem(); + header.type = FeatureItem.TYPE_HEADER; + header.title = (String) def; + displayList.add(header); + } else if (def instanceof List) { + @SuppressWarnings("unchecked") + List group = (List) def; + for (int i = 0; i < group.size(); i++) { + FeatureItem item = group.get(i); + item.segmentPosition = i; + item.segmentSize = group.size(); + displayList.add(item); + } + // Add a spacer after each group except the last definition + if (di < definitions.size() - 1) { + FeatureItem spacer = new FeatureItem(); + spacer.type = FeatureItem.TYPE_SPACER; + displayList.add(spacer); + } + } + } + + recyclerFeatures.setAlpha(0f); + recyclerFeatures.setTranslationY(60f); + + adapter.setItems(displayList); + recyclerFeatures.scrollToPosition(0); + + recyclerFeatures.animate() + .alpha(1f) + .translationY(0f) + .setDuration(250) + .setInterpolator(new DecelerateInterpolator()) + .start(); + } + + private FeatureItem createNav(String title, Runnable navAction) { + FeatureItem item = new FeatureItem(); + item.type = FeatureItem.TYPE_CLICKABLE; + item.title = title; + item.onClick = navAction; + return item; + } + + private FeatureItem createClickable(String title, int color, Runnable onClick) { + FeatureItem item = new FeatureItem(); + item.type = FeatureItem.TYPE_CLICKABLE; + item.title = title; + item.textColor = color; + item.onClick = onClick; + return item; + } + + private FeatureItem createSwitch(String title, String prefKey) { + FeatureItem item = new FeatureItem(); + item.type = FeatureItem.TYPE_SWITCH; + item.title = title; + item.prefKey = prefKey; + return item; + } + + private FeatureItem createSwitchWithDependency(String title, String prefKey, String dependsOn) { + FeatureItem item = createSwitch(title, prefKey); + item.dependsOn = dependsOn; + return item; + } + + private FeatureItem createSwitchWithCascadeOff(String title, String prefKey, String cascadeOffKey) { + FeatureItem item = createSwitch(title, prefKey); + item.cascadeOffKey = cascadeOffKey; + return item; + } + + private FeatureItem createMasterSwitch(String title, List childKeys) { + FeatureItem item = new FeatureItem(); + item.type = FeatureItem.TYPE_MASTER_SWITCH; + item.title = title; + item.childKeys = childKeys; + return item; + } + + // ========================================================= + // MENUS + // ========================================================= + + private void loadMainMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_categories)); + defs.add(Arrays.asList( + createNav(getString(R.string.ig_dialog_menu_dev_options), this::loadDevMenu), + createNav(getString(R.string.ig_dialog_menu_ghost_settings), this::loadGhostMenu), + createNav(getString(R.string.ig_dialog_menu_ad_analytics), this::loadAdsMenu), + createNav(getString(R.string.ig_dialog_menu_distraction_free), this::loadDistractionMenu), + createNav(getString(R.string.ig_dialog_menu_misc), this::loadMiscMenu), + createNav(getString(R.string.ig_dialog_menu_downloader), this::loadDownloaderMenu) + )); + + defs.add(getString(R.string.feat_tools)); + defs.add(Arrays.asList( + createClickable(getString(R.string.ig_dialog_backup_settings), 0, this::backupSettings), + createClickable(getString(R.string.ig_dialog_restore_settings), 0, this::restoreSettings), + createClickable(getString(R.string.ig_dialog_menu_about), 0, this::showAboutDialog), + createClickable(getString(R.string.ig_dialog_menu_restart), 0xFFFF453A, this::restartInstagram) + )); + + showMenu(getString(R.string.features), defs); + currentMenu = "main"; + } + + private void loadDevMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList(createSwitch(getString(R.string.ig_dialog_dev_enable), "isDevEnabled"))); + + defs.add(getString(R.string.feat_config)); + defs.add(Arrays.asList( + createClickable(getString(R.string.ig_dialog_dev_import), 0xFF30D158, this::importDevConfig), + createClickable(getString(R.string.ig_dialog_dev_export), 0xFF0A84FF, this::exportDevConfig) + )); + + defs.add(getString(R.string.feat_options)); + defs.add(Arrays.asList(createSwitch(getString(R.string.ig_dialog_dev_remove_build_expired), "removeBuildExpiredPopup"))); + + showMenu(getString(R.string.ig_dialog_section_dev_options), defs); + currentMenu = "dev"; + } + + private void loadGhostMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_quick_toggle)); + defs.add(Arrays.asList(createNav(getString(R.string.ig_dialog_customize_quick_toggle), this::loadQuickTogglesMenu))); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), Arrays.asList( + "isGhostSeen", "isGhostTyping", "isGhostStory", "isGhostLive", + "allowScreenshots", "isGhostScreenshot", "isGhostViewOnce", + "enableUnlimitedReplays", "permanentViewMode", "keepEphemeralMessages" + )), + createSwitch(getString(R.string.ig_dialog_ghost_hide_dm_seen), "isGhostSeen"), + createSwitch(getString(R.string.ig_dialog_ghost_hide_typing), "isGhostTyping"), + createSwitch(getString(R.string.ig_dialog_ghost_hide_story_views), "isGhostStory"), + createSwitch(getString(R.string.ig_dialog_ghost_hide_live_presence), "isGhostLive"), + createSwitch(getString(R.string.ig_dialog_ghost_allow_screenshots_dms), "allowScreenshots"), + createSwitch(getString(R.string.ig_dialog_ghost_bypass_screenshot), "isGhostScreenshot"), + createSwitch(getString(R.string.ig_dialog_ghost_hide_view_once), "isGhostViewOnce"), + createSwitch(getString(R.string.ig_dialog_ghost_unlimited_replays), "enableUnlimitedReplays"), + createSwitch(getString(R.string.ig_dialog_ghost_permanent_view_once), "permanentViewMode"), + createSwitch(getString(R.string.ig_dialog_ghost_keep_disappearing), "keepEphemeralMessages") + )); + + showMenu(getString(R.string.ig_dialog_section_ghost_mode), defs); + currentMenu = "ghost"; + } + + private void loadQuickTogglesMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), Arrays.asList( + "quickToggleSeen", "quickToggleTyping", "quickToggleScreenshot", + "quickToggleViewOnce", "quickToggleStory", "quickToggleLive", + "quickToggleEphemeral", "quickToggleReplays", "quickTogglePermanentView", + "quickToggleAllowScreenshots" + )), + createSwitch(getString(R.string.ig_dialog_quick_hide_seen), "quickToggleSeen"), + createSwitch(getString(R.string.ig_dialog_quick_hide_typing), "quickToggleTyping"), + createSwitch(getString(R.string.ig_dialog_quick_disable_screenshot), "quickToggleScreenshot"), + createSwitch(getString(R.string.ig_dialog_quick_hide_view_once), "quickToggleViewOnce"), + createSwitch(getString(R.string.ig_dialog_quick_hide_story_seen), "quickToggleStory"), + createSwitch(getString(R.string.ig_dialog_quick_hide_live_seen), "quickToggleLive"), + createSwitch(getString(R.string.ig_dialog_quick_keep_ephemeral), "quickToggleEphemeral"), + createSwitch(getString(R.string.ig_dialog_quick_unlimited_replays), "quickToggleReplays"), + createSwitch(getString(R.string.ig_dialog_quick_permanent_view), "quickTogglePermanentView"), + createSwitch(getString(R.string.ig_dialog_quick_allow_screenshots), "quickToggleAllowScreenshots") + )); + + showMenu(getString(R.string.ig_dialog_section_quick_toggle), defs); + currentMenu = "qt"; + } + + private void loadAdsMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), Arrays.asList("isAdBlockEnabled", "isAnalyticsBlocked", "disableTrackingLinks")), + createSwitch(getString(R.string.ig_dialog_ad_block_ads), "isAdBlockEnabled"), + createSwitch(getString(R.string.ig_dialog_ad_block_analytics), "isAnalyticsBlocked"), + createSwitch(getString(R.string.ig_dialog_ad_disable_tracking), "disableTrackingLinks") + )); + + showMenu(getString(R.string.ig_dialog_section_ad_analytics), defs); + currentMenu = "ads"; + } + + private void loadDistractionMenu() { + List defs = new ArrayList<>(); + + List distractionKeys = Arrays.asList( + "disableStories", "disableFeed", "disableReels", + "disableReelsExceptDM", "disableExplore", "disableComments" + ); + + // Extreme Mode: only enabled once the user has selected at least one feature + FeatureItem extreme = createSwitch(getString(R.string.ig_dialog_distraction_extreme_mode), "isExtremeMode"); + extreme.textColor = 0xFFFF453A; + extreme.isExtreme = true; + extreme.requiresAnyOf = distractionKeys; + + defs.add(getString(R.string.feat_danger_zone)); + defs.add(Arrays.asList(extreme)); + + // Master switch and all feature toggles: locked once extreme mode is active + FeatureItem masterSwitch = createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), distractionKeys); + masterSwitch.disabledWhenTrue = "isExtremeMode"; + + FeatureItem disableReels = createSwitchWithCascadeOff(getString(R.string.ig_dialog_distraction_disable_reels), "disableReels", "disableReelsExceptDM"); + disableReels.disabledWhenTrue = "isExtremeMode"; + + FeatureItem disableReelsExceptDM = createSwitchWithDependency(getString(R.string.ig_dialog_distraction_disable_reels_except_dm), "disableReelsExceptDM", "disableReels"); + disableReelsExceptDM.disabledWhenTrue = "isExtremeMode"; + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + masterSwitch, + createSwitchLockedByExtreme(getString(R.string.ig_dialog_distraction_disable_stories), "disableStories"), + createSwitchLockedByExtreme(getString(R.string.ig_dialog_distraction_disable_feed), "disableFeed"), + disableReels, + disableReelsExceptDM, + createSwitchLockedByExtreme(getString(R.string.ig_dialog_distraction_disable_explore), "disableExplore"), + createSwitchLockedByExtreme(getString(R.string.ig_dialog_distraction_disable_comments), "disableComments") + )); + + showMenu(getString(R.string.ig_dialog_section_distraction_free), defs); + currentMenu = "distract"; + } + + private FeatureItem createSwitchLockedByExtreme(String title, String prefKey) { + FeatureItem item = createSwitch(title, prefKey); + item.disabledWhenTrue = "isExtremeMode"; + return item; + } + + private void loadMiscMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), Arrays.asList( + "disableStoryFlipping", "disableVideoAutoPlay", "disableRepost", "showFollowerToast", + "showFeatureToasts", "enableStoryMentions", "disableDiscoverPeople", "enableCopyComment" + )), + createSwitch(getString(R.string.ig_dialog_misc_disable_story_autoswipe), "disableStoryFlipping"), + createSwitch(getString(R.string.ig_dialog_misc_disable_video_autoplay), "disableVideoAutoPlay"), + createSwitch(getString(R.string.ig_dialog_misc_disable_repost), "disableRepost"), + createSwitch(getString(R.string.ig_dialog_misc_show_follower_toast), "showFollowerToast"), + createSwitch(getString(R.string.ig_dialog_misc_show_feature_toasts), "showFeatureToasts"), + createSwitch(getString(R.string.ig_dialog_misc_view_story_mentions), "enableStoryMentions"), + createSwitch(getString(R.string.ig_dialog_misc_disable_discover_people), "disableDiscoverPeople"), + createSwitch(getString(R.string.ig_dialog_misc_copy_comment), "enableCopyComment") + )); + + showMenu(getString(R.string.ig_dialog_section_misc), defs); + currentMenu = "misc"; + } + + private void loadDownloaderMenu() { + List defs = new ArrayList<>(); + + defs.add(getString(R.string.feat_features)); + defs.add(Arrays.asList( + createMasterSwitch(getString(R.string.ig_dialog_enable_disable_all), Arrays.asList( + "enablePostDownload", "enableStoryDownload", "enableReelDownload", "enableProfileDownload" + )), + createSwitch(getString(R.string.ig_dialog_downloader_posts), "enablePostDownload"), + createSwitch(getString(R.string.ig_dialog_downloader_stories), "enableStoryDownload"), + createSwitch(getString(R.string.ig_dialog_downloader_reels), "enableReelDownload"), + createSwitch(getString(R.string.ig_dialog_downloader_profiles), "enableProfileDownload") + )); + + defs.add(getString(R.string.feat_options)); + defs.add(Arrays.asList( + createSwitch(getString(R.string.ig_dialog_downloader_username_subfolder), "downloaderUsernameFolder"), + createSwitch(getString(R.string.ig_dialog_downloader_add_timestamp), "downloaderAddTimestamp") + )); + + String customPath = localCache.getString("downloaderCustomPath", ""); + String folderTitle = customPath.isEmpty() + ? getString(R.string.feat_downloader_set_folder) + : getString(R.string.feat_downloader_selected, customPath); + + defs.add(getString(R.string.feat_download_folder)); + defs.add(Arrays.asList( + createClickable(folderTitle, 0, this::pickDownloadFolder), + createClickable(getString(R.string.feat_downloader_reset_folder), 0xFFFF453A, this::resetDownloadFolder) + )); + + showMenu(getString(R.string.ig_dialog_section_downloader), defs); + currentMenu = "downloader"; + } + + // ========================================================= + // TOOLS ACTIONS & HANDLERS + // ========================================================= + + /** + * Makes the localCache SharedPreferences file world-readable so the module can + * access it via XSharedPreferences on a cold Instagram start (when the sync + * broadcast was never delivered because Instagram wasn't running at the time). + * Apps are allowed to change permissions on their own files. + */ + private void makeLocalCacheWorldReadable() { + try { + java.io.File prefsFile = new java.io.File( + requireContext().getApplicationInfo().dataDir + "/shared_prefs/instaeclipse_cache.xml"); + prefsFile.setReadable(true, false); + } catch (Throwable ignored) {} + } + + private void pickDownloadFolder() { + dirPickerLauncher.launch(null); + } + + private void resetDownloadFolder() { + SharedPreferences.Editor editor = localCache.edit(); + editor.remove("pathJustSetLocally"); + editor.putString("downloaderCustomUri", ""); + editor.putString("downloaderCustomPath", ""); + editor.commit(); + makeLocalCacheWorldReadable(); + + Intent intentUri = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING"); + intentUri.putExtra("key", "downloaderCustomUri"); + intentUri.putExtra("value", ""); + requireContext().sendBroadcast(intentUri); + + Intent intentPath = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF_STRING"); + intentPath.putExtra("key", "downloaderCustomPath"); + intentPath.putExtra("value", ""); + requireContext().sendBroadcast(intentPath); + + Toast.makeText(requireContext(), getString(R.string.ig_toast_download_folder_reset), Toast.LENGTH_SHORT).show(); + + if ("downloader".equals(currentMenu)) { + loadDownloaderMenu(); + } + } + + private void backupSettings() { + try { + JSONObject settings = new JSONObject(); + Map all = localCache.getAll(); + for (Map.Entry entry : all.entrySet()) { + if (entry.getValue() instanceof Boolean) { + settings.put(entry.getKey(), entry.getValue()); + } + } + JSONObject root = new JSONObject(); + root.put("version", 1); + root.put("settings", settings); + Intent exportIntent = new Intent(); + exportIntent.setComponent(new ComponentName(requireContext(), "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); + exportIntent.putExtra("json_content", root.toString(2)); + exportIntent.putExtra("file_name", "instaeclipse_settings.json"); + startActivity(exportIntent); + } catch (JSONException e) { + Toast.makeText(requireContext(), e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void restoreSettings() { + restoreFileLauncher.launch(new String[]{"application/json"}); + } + + private void restartInstagram() { + Toast.makeText(requireContext(), getString(R.string.ig_toast_restart_manual), Toast.LENGTH_LONG).show(); + } + + private void importDevConfig() { + Intent importIntent = new Intent(); + importIntent.setComponent(new ComponentName(requireContext(), "ps.reso.instaeclipse.mods.devops.config.JsonImportActivity")); + importIntent.putExtra("target_package", "com.instagram.android"); + startActivity(importIntent); + } + + private void exportDevConfig() { + BroadcastReceiver configReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + context.unregisterReceiver(this); + Activity activity = getActivity(); + if (activity == null || activity.isFinishing()) return; + String error = intent.getStringExtra("error"); + if (error != null) { + Toast.makeText(activity, error, Toast.LENGTH_SHORT).show(); + return; + } + String json = intent.getStringExtra("json_content"); + if (json == null || json.isEmpty()) { + Toast.makeText(activity, getString(R.string.export_no_config_data), Toast.LENGTH_SHORT).show(); + return; + } + Intent exportIntent = new Intent(); + exportIntent.setComponent(new ComponentName(activity, "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); + exportIntent.putExtra("json_content", json); + startActivity(exportIntent); + } + }; + IntentFilter filter = new IntentFilter("ps.reso.instaeclipse.ACTION_SEND_CONFIG"); + if (Build.VERSION.SDK_INT >= 33) { + requireContext().registerReceiver(configReceiver, filter, Context.RECEIVER_EXPORTED); + } else { + ContextCompat.registerReceiver(requireContext(), configReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + } + Intent request = new Intent("ps.reso.instaeclipse.ACTION_EXPORT_CONFIG"); + request.setPackage("com.instagram.android"); + requireContext().sendBroadcast(request); + } + + private void showAboutDialog() { + new AlertDialog.Builder(requireContext()) + .setTitle("InstaEclipse 🌘") + .setMessage("Created by @reso7200\n\nGitHub: https://github.com/ReSo7200/InstaEclipse\nTelegram: https://t.me/InstaEclipse") + .setPositiveButton("Close", null) + .show(); + } + + // ========================================================= + // STAGING LOGIC + // ========================================================= + + private void stageChange(String prefKey, boolean isChecked) { + if (localCache.getBoolean(prefKey, false) == isChecked) { + stagedChanges.remove(prefKey); + } else { + stagedChanges.put(prefKey, isChecked); + } + + if (stagedChanges.isEmpty()) { + fabSave.hide(); + } else { + fabSave.show(); + } + } + + private void commitStagedChanges() { + if (stagedChanges.isEmpty()) return; + + SharedPreferences.Editor editor = localCache.edit(); + for (Map.Entry entry : stagedChanges.entrySet()) { + String key = entry.getKey(); + boolean value = entry.getValue(); + + editor.putBoolean(key, value); + + Intent intent = new Intent("ps.reso.instaeclipse.ACTION_UPDATE_PREF"); + intent.putExtra("key", key); + intent.putExtra("value", value); + requireContext().sendBroadcast(intent); + } + editor.apply(); + stagedChanges.clear(); + fabSave.hide(); + Toast.makeText(requireContext(), getString(R.string.ig_toast_settings_applied), Toast.LENGTH_SHORT).show(); + } + + private boolean getCurrentState(String prefKey) { + if (prefKey == null) return false; + if (stagedChanges.containsKey(prefKey)) return stagedChanges.get(prefKey); + return localCache.getBoolean(prefKey, false); + } + + // ========================================================= + // LIFECYCLE BROADCAST REGISTRATION + // ========================================================= + + @Override + public void onResume() { + super.onResume(); + IntentFilter filter = new IntentFilter("ps.reso.instaeclipse.ACTION_SEND_PREFS"); + if (Build.VERSION.SDK_INT >= 33) { + requireContext().registerReceiver(prefsReceiver, filter, Context.RECEIVER_EXPORTED); + } else { + ContextCompat.registerReceiver(requireContext(), prefsReceiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED); + } + requireContext().sendBroadcast(new Intent("ps.reso.instaeclipse.ACTION_REQUEST_PREFS")); + } + + @Override + public void onPause() { + super.onPause(); + requireContext().unregisterReceiver(prefsReceiver); + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/fragments/HomeFragment.java b/app/src/main/java/ps/reso/instaeclipse/fragments/HomeFragment.java index 922f0a4b..e8c287bc 100644 --- a/app/src/main/java/ps/reso/instaeclipse/fragments/HomeFragment.java +++ b/app/src/main/java/ps/reso/instaeclipse/fragments/HomeFragment.java @@ -1,15 +1,19 @@ package ps.reso.instaeclipse.fragments; +import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Intent; -import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.text.SpannableString; import android.view.LayoutInflater; +import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; +import android.view.ViewTreeObserver; +import android.view.animation.LinearInterpolator; +import android.widget.HorizontalScrollView; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; @@ -23,6 +27,7 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; @@ -32,11 +37,22 @@ public class HomeFragment extends Fragment { + // px/s scroll speed — feels like a gentle conveyor belt + private static final float SCROLL_SPEED_DP_PER_SEC = 48f; + private MaterialButton launchInstagramButton; private MaterialCardView instagramStatusCard; private TextView instagramStatusText; + private TextView instagramVariantText; + private MaterialButton instagramMultiButton; private ImageView instagramLogo, instagramInfoIcon; + private String activePackage; + private List installedPackages; + + private ValueAnimator contributorsAnimator; + private ValueAnimator specialThanksAnimator; + @Nullable @Override @@ -44,107 +60,143 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c @Nullable Bundle savedInstanceState) { View view = inflater.inflate(R.layout.fragment_home, container, false); - - // Initialize views launchInstagramButton = view.findViewById(R.id.launch_instagram_button); MaterialButton downloadButton = view.findViewById(R.id.download_instagram_button); - - // Find the Card, TextView and Logo to display Instagram status instagramStatusCard = view.findViewById(R.id.instagram_status_card); instagramStatusText = view.findViewById(R.id.instagram_status_text); + instagramVariantText = view.findViewById(R.id.instagram_variant_text); + instagramMultiButton = view.findViewById(R.id.instagram_multi_button); instagramLogo = view.findViewById(R.id.instagram_logo); instagramInfoIcon = view.findViewById(R.id.instagram_info_icon); - - // Check Instagram installation and version checkInstagramStatus(); - // Launch Instagram Button Listener - launchInstagramButton.setOnClickListener(v -> { - PackageManager pm = requireContext().getPackageManager(); - Intent launchIntent = pm.getLaunchIntentForPackage("com.instagram.android"); - if (launchIntent != null) { - startActivity(launchIntent); - } else { - Toast.makeText(getActivity(), getString(R.string.not_installed_instagram), Toast.LENGTH_SHORT).show(); - } - }); - - // Download APK Button Logic downloadButton.setOnClickListener(v -> { String url = "https://www.apkmirror.com/uploads/?appcategory=instagram-instagram"; - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(intent); + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); }); - // Setup Contributors and Special Thanks setupContributorsAndSpecialThanks(view); return view; } + @Override + public void onResume() { + super.onResume(); + resumeAnimators(); + } + + @Override + public void onPause() { + super.onPause(); + pauseAnimators(); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + if (contributorsAnimator != null) contributorsAnimator.cancel(); + if (specialThanksAnimator != null) specialThanksAnimator.cancel(); + } + @SuppressLint("SetTextI18n") private void checkInstagramStatus() { - String instagramPackage = CommonUtils.IG_PACKAGE_NAME; // IG package name - PackageManager pm = requireContext().getPackageManager(); // Get PackageManager + PackageManager pm = requireContext().getPackageManager(); + + installedPackages = new ArrayList<>(); + for (String pkg : CommonUtils.SUPPORTED_PACKAGES) { + try { + pm.getPackageInfo(pkg, 0); + installedPackages.add(pkg); + } catch (PackageManager.NameNotFoundException ignored) { + } + } - try { - PackageInfo packageInfo = pm.getPackageInfo(instagramPackage, 0); - String versionName = packageInfo.versionName; + if (installedPackages.isEmpty()) { + instagramStatusText.setText(getString(R.string.not_installed_instagram)); + instagramStatusText.setTypeface(null, android.graphics.Typeface.BOLD); + instagramStatusCard.setCardBackgroundColor(getResources().getColor(R.color.dark_red)); + instagramLogo.setImageResource(R.drawable.ic_cancel); + launchInstagramButton.setEnabled(false); + return; + } - String installedText = getString(R.string.installed_instagram_version); - String versionText = getString(R.string.instagram_version) + ": " + versionName; - String fullText = installedText + "\n" + versionText; + // Prefer the official package; fall back to first found mod + activePackage = installedPackages.contains(CommonUtils.IG_PACKAGE_NAME) + ? CommonUtils.IG_PACKAGE_NAME + : installedPackages.get(0); - SpannableString spannableString = new SpannableString(fullText); + instagramStatusCard.setCardBackgroundColor(getResources().getColor(R.color.green)); + instagramLogo.setImageResource(R.drawable.ic_instagram_logo); + instagramVariantText.setVisibility(View.VISIBLE); - spannableString.setSpan(new android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, installedText.length(), android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); - int versionStartIndex = installedText.length() + 1; - spannableString.setSpan(new android.text.style.RelativeSizeSpan(0.85f), versionStartIndex, fullText.length(), 0); + if (installedPackages.size() > 1) { + instagramMultiButton.setVisibility(View.VISIBLE); + instagramMultiButton.setOnClickListener(v -> showDetectedVersionsDialog(pm)); + } else { + instagramMultiButton.setVisibility(View.GONE); + } - instagramStatusText.setText(spannableString); - instagramStatusCard.setCardBackgroundColor(getResources().getColor(R.color.green)); - instagramLogo.setImageResource(R.drawable.ic_instagram_logo); + bindPackageActions(pm, activePackage); + } - // Add OnClickListener to open app settings if Instagram is installed - instagramInfoIcon.setOnClickListener(v -> { - Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - intent.setData(Uri.parse("package:" + instagramPackage)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(intent); - }); + @SuppressLint("SetTextI18n") + private void bindPackageActions(PackageManager pm, String pkg) { + activePackage = pkg; + try { + String versionName = pm.getPackageInfo(pkg, 0).versionName; + String installedText = getString(R.string.installed_instagram_version); + String versionText = getString(R.string.instagram_version) + ": " + versionName; + String fullText = installedText + "\n" + versionText; + SpannableString sp = new SpannableString(fullText); + sp.setSpan(new android.text.style.StyleSpan(android.graphics.Typeface.BOLD), 0, installedText.length(), android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); + sp.setSpan(new android.text.style.RelativeSizeSpan(0.85f), installedText.length() + 1, fullText.length(), 0); + instagramStatusText.setText(sp); } catch (PackageManager.NameNotFoundException e) { - instagramStatusText.setText(getString(R.string.not_installed_instagram)); - instagramStatusText.setTypeface(null, android.graphics.Typeface.BOLD); - instagramStatusCard.setCardBackgroundColor(getResources().getColor(R.color.dark_red)); - instagramLogo.setImageResource(R.drawable.ic_cancel); - launchInstagramButton.setBackgroundColor(android.graphics.Color.parseColor("#262626")); - - } catch (Exception e) { - instagramStatusText.setText(getString(R.string.error_instagram)); - instagramStatusText.setTypeface(null, android.graphics.Typeface.BOLD); - instagramStatusCard.setCardBackgroundColor(getResources().getColor(R.color.dark_red)); - instagramLogo.setImageResource(R.drawable.ic_error); - launchInstagramButton.setBackgroundColor(android.graphics.Color.parseColor("#262626")); + instagramStatusText.setText(getString(R.string.installed_instagram_version)); } - } + instagramVariantText.setText(CommonUtils.getVariantLabel(pkg)); + + instagramInfoIcon.setOnClickListener(v -> { + Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + intent.setData(Uri.parse("package:" + pkg)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startActivity(intent); + }); + + launchInstagramButton.setOnClickListener(v -> { + Intent launchIntent = pm.getLaunchIntentForPackage(pkg); + if (launchIntent != null) { + startActivity(launchIntent); + } else { + Toast.makeText(getActivity(), getString(R.string.not_installed_instagram), Toast.LENGTH_SHORT).show(); + } + }); + } private void setupContributorsAndSpecialThanks(View rootView) { + HorizontalScrollView contributorsScroll = rootView.findViewById(R.id.contributors_scroll); + HorizontalScrollView specialThanksScroll = rootView.findViewById(R.id.special_thanks_scroll); LinearLayout contributorsContainer = rootView.findViewById(R.id.contributors_container); LinearLayout specialThanksContainer = rootView.findViewById(R.id.special_thanks_container); List contributors = Arrays.asList( new Contributor("ReSo7200", "https://github.com/ReSo7200", "https://linkedin.com/in/abdalhaleem-altamimi", null), + new Contributor("swakwork", "https://github.com/swakwork", null, null), + new Contributor("isma3iloiso", "https://github.com/isma3iloiso", null, null), + new Contributor("Placeholder6", "https://github.com/Placeholder6", null, null), new Contributor("frknkrc44", "https://github.com/frknkrc44", null, null), new Contributor("BrianML", "https://github.com/brianml31", null, "https://t.me/instamoon_channel"), new Contributor("silvzr", "https://github.com/silvzr", null, null), new Contributor("oct", "https://github.com/oct888", null, null), new Contributor("HalfManBear", "https://github.com/halfmanbear", null, null), - new Contributor("ar5to", "https://github.com/ar5to", null, "https://t.me/ar5to") + new Contributor("ar5to", "https://github.com/ar5to", null, "https://t.me/ar5to"), + new Contributor("particle-box", "https://github.com/particle-box", null, null) ); List specialThanks = Arrays.asList( @@ -154,19 +206,103 @@ private void setupContributorsAndSpecialThanks(View rootView) { new Contributor("Amàzing World", null, null, null) ); - for (Contributor contributor : contributors) { - View contributorView = LayoutInflater.from(getContext()).inflate(R.layout.contributor_card, contributorsContainer, false); - setupContributorCard(contributorView, contributor); - contributorsContainer.addView(contributorView); - } + // Inflate each list twice so the loop restarts seamlessly + inflateCards(contributors, contributorsContainer); + inflateCards(contributors, contributorsContainer); + inflateCards(specialThanks, specialThanksContainer); + inflateCards(specialThanks, specialThanksContainer); + + // Start infinite scroll once layout is complete + contributorsContainer.getViewTreeObserver().addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + contributorsContainer.getViewTreeObserver().removeOnGlobalLayoutListener(this); + int halfWidth = contributorsContainer.getWidth() / 2; + contributorsAnimator = buildAnimator(contributorsScroll, halfWidth); + if (isResumed()) contributorsAnimator.start(); + hookTouchPause(contributorsScroll, contributorsAnimator, halfWidth); + } + }); + + specialThanksContainer.getViewTreeObserver().addOnGlobalLayoutListener( + new ViewTreeObserver.OnGlobalLayoutListener() { + @Override + public void onGlobalLayout() { + specialThanksContainer.getViewTreeObserver().removeOnGlobalLayoutListener(this); + int halfWidth = specialThanksContainer.getWidth() / 2; + specialThanksAnimator = buildAnimator(specialThanksScroll, halfWidth); + if (isResumed()) specialThanksAnimator.start(); + hookTouchPause(specialThanksScroll, specialThanksAnimator, halfWidth); + } + }); + } - for (Contributor thanks : specialThanks) { - View thanksView = LayoutInflater.from(getContext()).inflate(R.layout.contributor_card, specialThanksContainer, false); - setupContributorCard(thanksView, thanks); - specialThanksContainer.addView(thanksView); + private void inflateCards(List list, LinearLayout container) { + for (Contributor c : list) { + View v = LayoutInflater.from(getContext()).inflate(R.layout.contributor_card, container, false); + setupContributorCard(v, c); + container.addView(v); } } + /** + * Builds a ValueAnimator that scrolls from 0 to halfWidth (one full copy of the items) + * at a constant speed, restarting instantly — creating a seamless infinite loop. + */ + private ValueAnimator buildAnimator(HorizontalScrollView scrollView, int halfWidth) { + float density = getResources().getDisplayMetrics().density; + long durationMs = (long) (halfWidth / (SCROLL_SPEED_DP_PER_SEC * density) * 1000f); + + ValueAnimator anim = ValueAnimator.ofInt(0, halfWidth); + anim.setDuration(Math.max(durationMs, 1000)); + anim.setRepeatCount(ValueAnimator.INFINITE); + anim.setRepeatMode(ValueAnimator.RESTART); + anim.setInterpolator(new LinearInterpolator()); + anim.addUpdateListener(a -> scrollView.scrollTo((int) a.getAnimatedValue(), 0)); + return anim; + } + + @SuppressLint("ClickableViewAccessibility") + private void hookTouchPause(HorizontalScrollView scrollView, ValueAnimator animator, int halfWidth) { + scrollView.setOnTouchListener((v, event) -> { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_MOVE: + if (animator != null && animator.isRunning()) animator.pause(); + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (animator != null && halfWidth > 0) { + // Sync animator position to where the user left the scroll, + // then resume — so it continues from the dropped position. + int currentX = scrollView.getScrollX() % halfWidth; + animator.setCurrentFraction(currentX / (float) halfWidth); + if (animator.isPaused()) animator.resume(); + else if (!animator.isRunning()) animator.start(); + } + break; + } + return false; + }); + } + + private void resumeAnimators() { + resumeAnimator(contributorsAnimator); + resumeAnimator(specialThanksAnimator); + } + + private void resumeAnimator(ValueAnimator anim) { + if (anim == null) return; + if (anim.isPaused()) anim.resume(); + else if (!anim.isRunning()) anim.start(); + } + + private void pauseAnimators() { + if (contributorsAnimator != null && contributorsAnimator.isRunning()) contributorsAnimator.pause(); + if (specialThanksAnimator != null && specialThanksAnimator.isRunning()) specialThanksAnimator.pause(); + } + private void setupContributorCard(View view, Contributor contributor) { TextView nameTextView = view.findViewById(R.id.contributor_name); nameTextView.setText(contributor.name()); @@ -196,10 +332,33 @@ private void setupContributorCard(View view, Contributor contributor) { } } - private void openLink(String url) { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivity(intent); - } + private void showDetectedVersionsDialog(PackageManager pm) { + String[] labels = new String[installedPackages.size()]; + for (int i = 0; i < installedPackages.size(); i++) { + String pkg = installedPackages.get(i); + String version = "?"; + try { version = pm.getPackageInfo(pkg, 0).versionName; } + catch (PackageManager.NameNotFoundException ignored) { } + labels[i] = CommonUtils.getVariantLabel(pkg) + " — v" + version; + } + int currentIndex = installedPackages.indexOf(activePackage); + final int[] selectedIndex = {currentIndex}; + + new com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext()) + .setTitle("Detected versions") + .setSingleChoiceItems(labels, currentIndex, (dialog, which) -> selectedIndex[0] = which) + .setPositiveButton("Use this", (dialog, which) -> { + String chosen = installedPackages.get(selectedIndex[0]); + if (!chosen.equals(activePackage)) { + bindPackageActions(pm, chosen); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + private void openLink(String url) { + startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ads/AdBlocker.java b/app/src/main/java/ps/reso/instaeclipse/mods/ads/AdBlocker.java index 658e15b9..262356f9 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ads/AdBlocker.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ads/AdBlocker.java @@ -10,12 +10,29 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; public class AdBlocker { public void disableSponsoredContent(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + if (FeatureFlags.isAdBlockEnabled) param.setResult(false); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("AdBlocker", classLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + FeatureStatusTracker.setHooked("AdBlocker"); + return; + } + } + try { List methods = bridge.findMethod( FindMethod.create().matcher( @@ -34,15 +51,8 @@ public void disableSponsoredContent(DexKitBridge bridge, ClassLoader classLoader try { Method targetMethod = method.getMethodInstance(classLoader); - - XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - if (FeatureFlags.isAdBlockEnabled) { - param.setResult(false); // prevent ad - } - } - }); + DexKitCache.saveMethod("AdBlocker", targetMethod); + XposedBridge.hookMethod(targetMethod, hook); XposedBridge.log("(InstaEclipse | AdBlocker): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/BuildExpiredPopupHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/BuildExpiredPopupHook.java new file mode 100644 index 00000000..e48bbf27 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/devops/BuildExpiredPopupHook.java @@ -0,0 +1,120 @@ +package ps.reso.instaeclipse.mods.devops; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Method; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +public class BuildExpiredPopupHook { + + private static final String CACHE_SHOW = "BuildExpiredShow"; + private static final String CACHE_CHECK = "BuildExpiredCheck"; + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + + // No-op the method that shows the popup — blocks all three internal paths: + // 1. lockout_active pref = true → shows immediately + // 2. snooze expired → shows via snooze dialog + // 3. age threshold exceeded → shows force-update dialog + XC_MethodHook noOpHook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.removeBuildExpiredPopup) { + param.setResult(null); // void return → method becomes no-op + } + } + }; + + // Secondary defence: hook the snooze-expired boolean check. + // Returns false so even if the show method is not found, the snooze + // check keeps reporting "not expired". + XC_MethodHook falseHook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.removeBuildExpiredPopup) { + param.setResult(false); + } + } + }; + + boolean hookedMain = false; + + // ── Cache path ──────────────────────────────────────────────────────── + if (DexKitCache.isCacheValid()) { + Method show = DexKitCache.loadMethod(CACHE_SHOW, classLoader); + if (show != null) { + XposedBridge.hookMethod(show, noOpHook); + FeatureStatusTracker.setHooked("RemoveBuildExpiredPopup"); + hookedMain = true; + } + Method check = DexKitCache.loadMethod(CACHE_CHECK, classLoader); + if (check != null) { + XposedBridge.hookMethod(check, falseHook); + } + if (hookedMain) return; + } + + // ── DexKit path ─────────────────────────────────────────────────────── + try { + // Primary: find the show-popup method via "lockout_active" string. + List showMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("lockout_active") + .returnType("void"))); + + for (MethodData md : showMethods) { + Method method; + try { method = md.getMethodInstance(classLoader); } catch (Throwable e) { continue; } + + Class[] params = method.getParameterTypes(); + if (params.length < 1) continue; + // Must take FragmentActivity as first arg; skip boolean-only variants + if (!params[0].getName().contains("FragmentActivity")) continue; + + XposedBridge.hookMethod(method, noOpHook); + DexKitCache.saveMethod(CACHE_SHOW, method); + XposedBridge.log("(IE|BuildExpired) ✅ hooked show-popup → " + + md.getClassName() + "." + md.getName()); + FeatureStatusTracker.setHooked("RemoveBuildExpiredPopup"); + hookedMain = true; + break; + } + + if (!hookedMain) { + XposedBridge.log("(IE|BuildExpired) ⚠️ show-popup method not found, falling back to boolean hook only"); + } + + // Secondary: hook the snooze-expired boolean check + List checkMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("snooze_expiration_lockout_manager") + .returnType("boolean"))); + + for (MethodData md : checkMethods) { + Method method; + try { method = md.getMethodInstance(classLoader); } catch (Throwable e) { continue; } + + XposedBridge.hookMethod(method, falseHook); + DexKitCache.saveMethod(CACHE_CHECK, method); + XposedBridge.log("(IE|BuildExpired) ✅ hooked snooze-check → " + + md.getClassName() + "." + md.getName()); + if (!hookedMain) { + FeatureStatusTracker.setHooked("RemoveBuildExpiredPopup"); + } + break; + } + + } catch (Throwable t) { + XposedBridge.log("(IE|BuildExpired) ❌ install: " + t); + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsEnable.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsEnable.java deleted file mode 100644 index 678aeb73..00000000 --- a/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsEnable.java +++ /dev/null @@ -1,134 +0,0 @@ -package ps.reso.instaeclipse.mods.devops; - -import org.luckypray.dexkit.DexKitBridge; -import org.luckypray.dexkit.query.FindClass; -import org.luckypray.dexkit.query.FindMethod; -import org.luckypray.dexkit.query.matchers.ClassMatcher; -import org.luckypray.dexkit.query.matchers.MethodMatcher; -import org.luckypray.dexkit.result.ClassData; -import org.luckypray.dexkit.result.MethodData; - -import java.lang.reflect.Method; -import java.util.List; - -import de.robv.android.xposed.XC_MethodHook; -import de.robv.android.xposed.XposedBridge; -import ps.reso.instaeclipse.Xposed.Module; -import ps.reso.instaeclipse.utils.feature.FeatureFlags; -import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; - -public class DevOptionsEnable { - - public void handleDevOptions(DexKitBridge bridge) { - try { - findAndHookDynamicMethod(bridge); - } catch (Exception e) { - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error handling Dev Options: " + e.getMessage()); - } - } - - private void findAndHookDynamicMethod(DexKitBridge bridge) { - try { - // Step 1: Find classes referencing "is_employee" - List classes = bridge.findClass(FindClass.create() - .matcher(ClassMatcher.create().usingStrings("is_employee")) - ); - - if (classes.isEmpty()) return; - - for (ClassData classData : classes) { - String className = classData.getName(); - if (!className.startsWith("X.")) continue; - - // Step 2: Find methods referencing "is_employee" within the class - List methods = bridge.findMethod(FindMethod.create() - .matcher(MethodMatcher.create() - .declaredClass(className) - .usingStrings("is_employee")) - ); - - if (methods.isEmpty()) continue; - - for (MethodData method : methods) { - inspectInvokedMethods(bridge, method); - } - } - } catch (Exception e) { - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error during discovery: " + e.getMessage()); - } - } - - private void inspectInvokedMethods(DexKitBridge bridge, MethodData method) { - try { - List invokedMethods = method.getInvokes(); - if (invokedMethods.isEmpty()) return; - - for (MethodData invokedMethod : invokedMethods) { - String returnType = String.valueOf(invokedMethod.getReturnType()); - - if (!returnType.contains("boolean")) continue; - - List paramTypes = new java.util.ArrayList<>(); - for (Object param : invokedMethod.getParamTypes()) { - paramTypes.add(String.valueOf(param)); - } - - if (paramTypes.size() == 1 && - paramTypes.get(0).contains("com.instagram.common.session.UserSession")) { - - String targetClass = invokedMethod.getClassName(); - XposedBridge.log("(InstaEclipse | DevOptionsEnable): 📦 Hooking boolean methods in: " + targetClass); - hookAllBooleanMethodsInClass(bridge, targetClass); - return; - } - } - } catch (Exception e) { - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error inspecting invoked methods: " + e.getMessage()); - } - } - - private void hookAllBooleanMethodsInClass(DexKitBridge bridge, String className) { - try { - List methods = bridge.findMethod(FindMethod.create() - .matcher(MethodMatcher.create() - .declaredClass(className)) - ); - - for (MethodData method : methods) { - String returnType = String.valueOf(method.getReturnType()); - List paramTypes = new java.util.ArrayList<>(); - for (Object param : method.getParamTypes()) { - paramTypes.add(String.valueOf(param)); - } - - if (returnType.contains("boolean") && - paramTypes.size() == 1 && - paramTypes.get(0).contains("com.instagram.common.session.UserSession")) { - - try { - Method targetMethod = method.getMethodInstance(Module.hostClassLoader); - - XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) { - if (FeatureFlags.isDevEnabled) { - param.setResult(true); - FeatureStatusTracker.setHooked("DevOptions"); - } - } - }); - - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ✅ Hooked: " + - method.getClassName() + "." + method.getName()); - - } catch (Throwable e) { - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Failed to hook " + method.getName() + ": " + e.getMessage()); - } - } - } - - } catch (Exception e) { - XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error while hooking class: " + className + " → " + e.getMessage()); - } - } -} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsUnlockHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsUnlockHook.java new file mode 100644 index 00000000..fd0fb884 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/devops/DevOptionsUnlockHook.java @@ -0,0 +1,195 @@ +package ps.reso.instaeclipse.mods.devops; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindClass; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.ClassMatcher; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.ClassData; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +public class DevOptionsUnlockHook { + + private static final long IS_EMPLOYEE_CONFIG_ID = 36310864701161762L; + + public void handleDevOptions(DexKitBridge bridge) { + if (DexKitCache.isCacheValid()) { + String cachedClass = DexKitCache.loadString("DevOptionsClass"); + if (cachedClass != null) { + hookBooleanMethodsViaReflection(cachedClass); + return; + } + } + try { + findAndHookDynamicMethod(bridge); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error handling Dev Options: " + e.getMessage()); + } + } + + private void findAndHookDynamicMethod(DexKitBridge bridge) { + try { + // Tier 1: Existing String-based search + XposedBridge.log("(InstaEclipse | DevOptionsEnable): 🔍 Discovery Tier 1 (String)..."); + List classes = bridge.findClass(FindClass.create() + .matcher(ClassMatcher.create().usingStrings("is_employee")) + ); + + boolean found = false; + if (!classes.isEmpty()) { + for (ClassData classData : classes) { + String className = classData.getName(); + if (!className.startsWith("X.")) continue; + + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .declaredClass(className) + .usingStrings("is_employee")) + ); + + for (MethodData method : methods) { + if (inspectInvokedMethods(bridge, method)) { + found = true; + break; + } + } + if (found) break; + } + } + + // Tier 2: Failover to MobileConfig ID (The "Golden Anchor") + if (!found) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ⚠️ Tier 1 failed. Discovery Tier 2 (Config ID)..."); + List idMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingNumbers(IS_EMPLOYEE_CONFIG_ID) + .returnType("boolean") + .paramCount(1)) + ); + + if (!idMethods.isEmpty()) { + String targetClass = idMethods.get(0).getClassName(); + XposedBridge.log("(InstaEclipse | DevOptionsEnable): 🎯 Found via Config ID in: " + targetClass); + DexKitCache.saveString("DevOptionsClass", targetClass); + hookAllBooleanMethodsInClass(bridge, targetClass); + found = true; + } + } + + // Final Debug Trace: If both fail, log where the string is used ANYWHERE + if (!found) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Tier 2 failed. Debugging global references..."); + List debugMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create().usingStrings("is_employee"))); + for (MethodData m : debugMethods) { + XposedBridge.log("(InstaEclipse | DevOptionsDebug): String 'is_employee' found in: " + m.getClassName() + "." + m.getName()); + } + } + + } catch (Exception e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error during discovery: " + e.getMessage()); + } + } + + private boolean inspectInvokedMethods(DexKitBridge bridge, MethodData method) { + try { + List invokedMethods = method.getInvokes(); + if (invokedMethods.isEmpty()) return false; + + for (MethodData invokedMethod : invokedMethods) { + String returnType = String.valueOf(invokedMethod.getReturnType()); + if (!returnType.contains("boolean")) continue; + + List paramTypes = new ArrayList<>(); + for (Object param : invokedMethod.getParamTypes()) { + paramTypes.add(String.valueOf(param)); + } + + if (paramTypes.size() == 1 && paramTypes.get(0).contains("com.instagram.common.session.UserSession")) { + String targetClass = invokedMethod.getClassName(); + XposedBridge.log("(InstaEclipse | DevOptionsEnable): 📦 Hooking via String detection: " + targetClass); + DexKitCache.saveString("DevOptionsClass", targetClass); + hookAllBooleanMethodsInClass(bridge, targetClass); + return true; + } + } + } catch (Exception e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error inspecting invoked methods: " + e.getMessage()); + } + return false; + } + + private void hookBooleanMethodsViaReflection(String className) { + try { + Class clazz = Module.hostClassLoader.loadClass(className); + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.isDevEnabled) { + param.setResult(true); + FeatureStatusTracker.setHooked("DevOptions"); + } + } + }; + for (Method m : clazz.getDeclaredMethods()) { + if (m.getReturnType() != boolean.class) continue; + Class[] params = m.getParameterTypes(); + if (params.length != 1) continue; + if (!params[0].getName().equals("com.instagram.common.session.UserSession")) continue; + m.setAccessible(true); + XposedBridge.hookMethod(m, hook); + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ✅ Hooked (cache): " + className + "." + m.getName()); + } + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Reflection fallback failed: " + e.getMessage()); + } + } + + private void hookAllBooleanMethodsInClass(DexKitBridge bridge, String className) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create().declaredClass(className)) + ); + + for (MethodData method : methods) { + String returnType = String.valueOf(method.getReturnType()); + List paramTypes = new ArrayList<>(); + for (Object param : method.getParamTypes()) { + paramTypes.add(String.valueOf(param)); + } + + if (returnType.contains("boolean") && paramTypes.size() == 1 && paramTypes.get(0).contains("com.instagram.common.session.UserSession")) { + try { + Method targetMethod = method.getMethodInstance(Module.hostClassLoader); + XposedHelpers.findAndHookMethod(targetMethod.getDeclaringClass(), targetMethod.getName(), targetMethod.getParameterTypes()[0], new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.isDevEnabled) { + param.setResult(true); + FeatureStatusTracker.setHooked("DevOptions"); + } + } + }); + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ✅ Hooked: " + method.getClassName() + "." + method.getName()); + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Failed to hook " + method.getName() + ": " + e.getMessage()); + } + } + } + } catch (Exception e) { + XposedBridge.log("(InstaEclipse | DevOptionsEnable): ❌ Error while hooking class: " + className + " → " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/ConfigManager.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/ConfigManager.java index 7eea190b..70efae04 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/ConfigManager.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/ConfigManager.java @@ -1,44 +1,25 @@ package ps.reso.instaeclipse.mods.devops.config; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Context; import android.os.Handler; import android.os.Looper; import android.widget.Toast; -import java.io.BufferedReader; import java.io.File; import java.io.FileOutputStream; -import java.io.FileReader; import java.nio.charset.StandardCharsets; import de.robv.android.xposed.XposedBridge; -import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.i18n.I18n; public class ConfigManager { - // Import meta config from clipboard - public static void importConfigFromClipboard(Context context) { - - android.app.ProgressDialog progress = new android.app.ProgressDialog(context); - progress.setMessage("Importing config..."); - progress.setCancelable(false); - progress.show(); - + public static void importConfigFromJson(Context context, String json) { new Thread(() -> { - ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); try { - if (clipboard == null || !clipboard.hasPrimaryClip()) throw new IllegalStateException("Empty clipboard"); - - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null || clipData.getItemCount() == 0) throw new IllegalStateException("Empty clipboard"); - - CharSequence clipText = clipData.getItemAt(0).getText(); - if (clipText == null || clipText.length() == 0) throw new IllegalStateException("Empty clipboard"); - - String json = clipText.toString().trim(); - if (!json.startsWith("{") || !json.endsWith("}")) throw new IllegalArgumentException("Clipboard is not valid JSON"); + if (json == null || json.isEmpty()) throw new IllegalArgumentException("Empty JSON"); + if (!json.startsWith("{") || !json.endsWith("}")) throw new IllegalArgumentException("Not valid JSON"); File dest = new File(context.getFilesDir(), "mobileconfig/mc_overrides.json"); File parent = dest.getParentFile(); @@ -49,67 +30,16 @@ public static void importConfigFromClipboard(Context context) { fos.flush(); } - // (Optional) clear clipboard to avoid stale re-imports later - try { clipboard.setPrimaryClip(ClipData.newPlainText("", "")); } catch (Exception ignored) {} - new Handler(Looper.getMainLooper()).post(() -> { - progress.dismiss(); - Toast.makeText(context, "✅ Imported into mc_overrides.json", Toast.LENGTH_LONG).show(); - XposedBridge.log("InstaEclipse | ✅ JSON imported from clipboard into mc_overrides.json"); + Toast.makeText(context.getApplicationContext(), I18n.t(context, R.string.ig_toast_config_imported), Toast.LENGTH_LONG).show(); + XposedBridge.log("InstaEclipse | ✅ JSON imported into mc_overrides.json"); }); } catch (Exception e) { - XposedBridge.log("InstaEclipse | ❌ Clipboard import failed: " + e.getMessage()); - new Handler(Looper.getMainLooper()).post(() -> { - progress.dismiss(); - Toast.makeText(context, "❌ Failed to import config", Toast.LENGTH_LONG).show(); - }); - } finally { - // 100% guarantee the flag is OFF after an attempt - FeatureFlags.isImportingConfig = false; + XposedBridge.log("InstaEclipse | ❌ Import failed: " + e.getMessage()); + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context.getApplicationContext(), I18n.t(context, R.string.ig_toast_config_import_failed), Toast.LENGTH_LONG).show() + ); } }).start(); } - - - // Export meta config to Device - public static void exportCurrentDevConfig(Context context) { - if (!FeatureFlags.isExportingConfig) { - return; - } - try { - File source = new File(context.getFilesDir(), "mobileconfig/mc_overrides.json"); - if (!source.exists()) { - XposedBridge.log("InstaEclipse | ❌ mc_overrides.json not found."); - return; - } - - StringBuilder jsonBuilder = new StringBuilder(); - try (BufferedReader reader = new BufferedReader(new FileReader(source))) { - String line; - while ((line = reader.readLine()) != null) { - jsonBuilder.append(line).append("\n"); - } - } - - String jsonContent = jsonBuilder.toString().trim(); - - if (!jsonContent.startsWith("{") || !jsonContent.endsWith("}")) { - XposedBridge.log("InstaEclipse | ❌ mc_overrides.json does not contain valid JSON."); - return; - } - - ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE); - if (clipboard != null) { - ClipData clip = ClipData.newPlainText("json", jsonContent); - clipboard.setPrimaryClip(clip); - XposedBridge.log("InstaEclipse | ✅ Copied mc_overrides.json to clipboard."); - } - - } catch (Exception e) { - XposedBridge.log("InstaEclipse | ❌ Failed to export config: " + e.getMessage()); - } finally { - FeatureFlags.isExportingConfig = false; - } - - } -} +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonExportActivity.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonExportActivity.java index 510f9eae..7d0b6bcc 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonExportActivity.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonExportActivity.java @@ -1,88 +1,58 @@ package ps.reso.instaeclipse.mods.devops.config; import android.app.Activity; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Intent; import android.net.Uri; import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; import android.widget.Toast; import java.io.OutputStream; import java.nio.charset.StandardCharsets; +import ps.reso.instaeclipse.R; + public class JsonExportActivity extends Activity { private static final int SAVE_JSON_FILE = 5678; + private String jsonContent; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - openJsonSaver(); - } - - private void openJsonSaver() { + jsonContent = getIntent().getStringExtra("json_content"); + if (jsonContent == null || jsonContent.isEmpty()) { + Toast.makeText(this, getString(R.string.export_no_config_data), Toast.LENGTH_LONG).show(); + finish(); + return; + } Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); intent.addCategory(Intent.CATEGORY_OPENABLE); intent.setType("application/json"); - intent.putExtra(Intent.EXTRA_TITLE, "mc_overrides_exported.json"); + String fileName = getIntent().getStringExtra("file_name"); + intent.putExtra(Intent.EXTRA_TITLE, (fileName != null && !fileName.isEmpty()) ? fileName : "mc_overrides_exported.json"); startActivityForResult(intent, SAVE_JSON_FILE); } @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { - if (requestCode == SAVE_JSON_FILE && resultCode == RESULT_OK && data != null) { - Uri uri = data.getData(); - if (uri == null) { - Toast.makeText(this, "❌ Invalid URI", Toast.LENGTH_SHORT).show(); - finish(); - return; - } - - new Handler(Looper.getMainLooper()).postDelayed(() -> { - ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); - if (clipboard == null || !clipboard.hasPrimaryClip()) { - Toast.makeText(this, "❌ Clipboard is empty.", Toast.LENGTH_LONG).show(); + if (requestCode == SAVE_JSON_FILE) { + if (resultCode == RESULT_OK && data != null) { + Uri uri = data.getData(); + if (uri == null) { + Toast.makeText(this, getString(R.string.export_invalid_uri), Toast.LENGTH_SHORT).show(); finish(); return; } - - ClipData clipData = clipboard.getPrimaryClip(); - if (clipData == null || clipData.getItemCount() == 0) { - Toast.makeText(this, "❌ Clipboard has no data.", Toast.LENGTH_LONG).show(); - finish(); - return; - } - - CharSequence text = clipData.getItemAt(0).getText(); - if (text == null || text.length() == 0) { - Toast.makeText(this, "❌ Clipboard text is empty.", Toast.LENGTH_LONG).show(); - finish(); - return; - } - - String json = text.toString().trim(); - if (!json.startsWith("{") || !json.endsWith("}")) { - Toast.makeText(this, "❌ Clipboard does not contain valid JSON.", Toast.LENGTH_LONG).show(); - finish(); - return; - } - - try (OutputStream outputStream = getContentResolver().openOutputStream(uri)) { - assert outputStream != null; - outputStream.write(json.getBytes(StandardCharsets.UTF_8)); - outputStream.flush(); - Toast.makeText(this, "✅ JSON exported successfully.", Toast.LENGTH_LONG).show(); - + try (OutputStream out = getContentResolver().openOutputStream(uri)) { + assert out != null; + out.write(jsonContent.getBytes(StandardCharsets.UTF_8)); + out.flush(); + Toast.makeText(this, getString(R.string.export_config_success), Toast.LENGTH_LONG).show(); } catch (Exception e) { - Toast.makeText(this, "❌ Failed to save file: " + e.getMessage(), Toast.LENGTH_LONG).show(); + Toast.makeText(this, getString(R.string.export_config_failed, e.getMessage()), Toast.LENGTH_LONG).show(); } - - finish(); - - }, 300); // Delay of 300ms + } + finish(); } } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonImportActivity.java b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonImportActivity.java index 915fc74a..4c37923d 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonImportActivity.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/devops/config/JsonImportActivity.java @@ -2,8 +2,6 @@ import android.annotation.SuppressLint; import android.app.Activity; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Intent; import android.net.Uri; import android.os.Bundle; @@ -13,60 +11,58 @@ import java.nio.charset.StandardCharsets; import java.util.Scanner; -import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.R; public class JsonImportActivity extends Activity { private static final int PICK_JSON_FILE = 1234; + static final String ACTION_IMPORT_CONFIG = "ps.reso.instaeclipse.ACTION_IMPORT_CONFIG"; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - FeatureFlags.isImportingConfig = false; - openJsonPicker(); - } - - private void openJsonPicker() { Intent intent = new Intent(Intent.ACTION_GET_CONTENT); intent.setType("application/json"); intent.addCategory(Intent.CATEGORY_OPENABLE); - startActivityForResult(Intent.createChooser(intent, "Select JSON Config"), PICK_JSON_FILE); + startActivityForResult(Intent.createChooser(intent, getString(R.string.json_select_config)), PICK_JSON_FILE); } + @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == PICK_JSON_FILE) { if (resultCode == RESULT_OK && data != null) { Uri uri = data.getData(); try (InputStream inputStream = getContentResolver().openInputStream(uri)) { String json = readStream(inputStream).trim(); - - // Validate before enabling the flag if (json.startsWith("{") && json.endsWith("}")) { - ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE); - ClipData clip = ClipData.newPlainText("json", json); - clipboard.setPrimaryClip(clip); - - FeatureFlags.isImportingConfig = true; // <- only now turn it ON - //Toast.makeText(this, "Config copied, returning to import…", Toast.LENGTH_SHORT).show(); + String targetPackage = getIntent().getStringExtra("target_package"); + if (targetPackage == null || targetPackage.isEmpty()) { + Toast.makeText(this, getString(R.string.json_target_not_specified), Toast.LENGTH_LONG).show(); + } else { + String action = getIntent().getStringExtra("broadcast_action"); + if (action == null || action.isEmpty()) action = ACTION_IMPORT_CONFIG; + Intent broadcast = new Intent(action); + broadcast.setPackage(targetPackage); + broadcast.putExtra("json_content", json); + sendBroadcast(broadcast); + Toast.makeText(this, getString(R.string.json_sent), Toast.LENGTH_SHORT).show(); + } } else { - FeatureFlags.isImportingConfig = false; - Toast.makeText(this, "❌ Not a valid JSON file", Toast.LENGTH_LONG).show(); + Toast.makeText(this, getString(R.string.json_not_valid), Toast.LENGTH_LONG).show(); } } catch (Exception e) { - FeatureFlags.isImportingConfig = false; // <- make sure we reset on error - Toast.makeText(this, "❌ Failed to read file: " + e.getMessage(), Toast.LENGTH_LONG).show(); + Toast.makeText(this, getString(R.string.json_read_failed, e.getMessage()), Toast.LENGTH_LONG).show(); } } else { - // User pressed back / cancelled - FeatureFlags.isImportingConfig = false; // <- ensure OFF on cancel - Toast.makeText(this, "Cancelled or no file selected", Toast.LENGTH_SHORT).show(); + Toast.makeText(this, getString(R.string.json_cancelled), Toast.LENGTH_SHORT).show(); } } - finish(); // Done, return to Instagram + finish(); } + @SuppressLint("NewApi") private String readStream(InputStream inputStream) { - @SuppressLint({"NewApi", "LocalSuppress"}) Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8).useDelimiter("\\A"); + Scanner scanner = new Scanner(inputStream, StandardCharsets.UTF_8).useDelimiter("\\A"); return scanner.hasNext() ? scanner.next() : ""; } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostChannelMarkAsReadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostChannelMarkAsReadHook.java new file mode 100644 index 00000000..74b28d47 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostChannelMarkAsReadHook.java @@ -0,0 +1,118 @@ +package ps.reso.instaeclipse.mods.ghost; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.graphics.Color; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; +import android.widget.Toast; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.i18n.I18n; + +public class GhostChannelMarkAsReadHook { + + private static final String CHANNEL_TAG = "ie_channel_seen"; + + public void install(ClassLoader classLoader) { + try { + XposedHelpers.findAndHookMethod(View.class, "onAttachedToWindow", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + View view = (View) param.thisObject; + Context context = view.getContext(); + + if (!FeatureFlags.isGhostSeen) return; + + // Target the specific seen state text ID used in communities/channels + @SuppressLint("DiscouragedApi") + int seenStateId = context.getResources().getIdentifier( + "seen_state_text", "id", context.getPackageName()); + + if (view.getId() == seenStateId && view instanceof TextView seenTextView) { + + // Get the ID for the buttons container + @SuppressLint("DiscouragedApi") + int composerContainerId = context.getResources().getIdentifier( + "header_right_buttons", "id", context.getPackageName()); + + if (composerContainerId != 0) { + View container = view.getRootView().findViewById(composerContainerId); + + if (container instanceof ViewGroup viewGroup) { + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + CharSequence description = child.getContentDescription(); + + if (description != null) { + String descStr = description.toString().toLowerCase(); + + // If it's a DM-specific button (Call or Blend), exit the hook + if (descStr.contains("audio call") || + descStr.contains("video call") || + descStr.contains("blend")) { + return; + } + } + } + } + } + updateChannelSeen(seenTextView); + } + } + }); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse): Channel seen hook failed: " + t.getMessage()); + } + } + + private void updateChannelSeen(TextView textView) { + // Prevent multiple listeners/updates + if (textView.getTag() != null && textView.getTag().equals(CHANNEL_TAG)) return; + textView.setTag(CHANNEL_TAG); + + // Make it look interactive + textView.setTextColor(Color.CYAN); // Distinguish it as a "modded" element + + textView.setOnClickListener(v -> { + triggerChannelSeen(textView); + }); + + // Optional: Append a ghost emoji to indicate it's modded + String currentText = textView.getText().toString(); + if (!currentText.contains("👻")) { + textView.setText(currentText + " 👻"); + } + } + + private void triggerChannelSeen(View view) { + try { + Context ctx = view.getContext(); + @SuppressLint("DiscouragedApi") + int messageListId = ctx.getResources().getIdentifier("message_list", "id", ctx.getPackageName()); + + View root = view.getRootView(); + View messageList = root.findViewById(messageListId); + + if (messageList instanceof ViewGroup group) { + group.scrollBy(0, 100_000); + + FeatureFlags.isGhostSeen = false; + group.scrollBy(0, -300); + + view.postDelayed(() -> { + group.scrollBy(0, 300); + FeatureFlags.isGhostSeen = true; + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_channel_seen_sent), Toast.LENGTH_SHORT).show(); + }, 400); + } + } catch (Exception e) { + FeatureFlags.isGhostSeen = true; + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMMarkAsReadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMMarkAsReadHook.java new file mode 100644 index 00000000..8f34599d --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMMarkAsReadHook.java @@ -0,0 +1,120 @@ +package ps.reso.instaeclipse.mods.ghost; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.XModuleResources; +import android.graphics.Color; +import android.graphics.drawable.Drawable; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.Toast; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.i18n.I18n; + +public class GhostDMMarkAsReadHook { + + private static final String GHOST_BTN_TAG = "ie_ghost_seen_btn"; + private final String moduleSourceDir; + + public GhostDMMarkAsReadHook(String moduleSourceDir) { + this.moduleSourceDir = moduleSourceDir; + } + + public void install(ClassLoader classLoader) { + try { + XposedHelpers.findAndHookMethod(View.class, "onAttachedToWindow", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + View view = (View) param.thisObject; + Context context = view.getContext(); + + @SuppressLint("DiscouragedApi") + int containerId = context.getResources().getIdentifier( + "row_thread_composer_buttons_container", "id", context.getPackageName()); + + // When we find the button container, we target its PARENT + if (view.getId() == containerId && view.getParent() instanceof ViewGroup parent) { + if (!FeatureFlags.isGhostSeen) return; + injectIndependentButton(parent, view); + } + } + }); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse): Ghost hook failed: " + t.getMessage()); + } + } + + private void injectIndependentButton(ViewGroup parent, View originalContainer) { + if (parent.findViewWithTag(GHOST_BTN_TAG) != null) return; + + Context ctx = parent.getContext(); + ImageButton ghostBtn = new ImageButton(ctx); + ghostBtn.setTag(GHOST_BTN_TAG); + + try { + @SuppressLint("UseCompatLoadingForDrawables") Drawable icon = XModuleResources.createInstance(moduleSourceDir, null) + .getDrawable(R.drawable.ic_eye, null); + ghostBtn.setImageDrawable(icon); + } catch (Exception e) { + ghostBtn.setImageResource(android.R.drawable.ic_menu_view); + ghostBtn.setColorFilter(Color.WHITE); + } + ghostBtn.setBackground(null); + + int size = dp(ctx, 35); + + // We use FrameLayout params because most Instagram composer parents are FrameLayouts + FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(size, size); + + // POSITION: Left side, slightly elevated so it doesn't block the text input or mic + lp.gravity = Gravity.CENTER_VERTICAL | Gravity.START; + lp.setMargins(dp(ctx, 5), 25, 0, 0); + + ghostBtn.setLayoutParams(lp); + ghostBtn.setOnClickListener(v -> triggerSeenLogic(parent)); + + parent.post(() -> { + parent.addView(ghostBtn, 3); + }); + } + + private void triggerSeenLogic(View view) { + try { + Context ctx = view.getContext(); + @SuppressLint("DiscouragedApi") + int messageListId = ctx.getResources().getIdentifier("message_list", "id", ctx.getPackageName()); + + View root = view.getRootView(); + View messageList = root.findViewById(messageListId); + + if (messageList instanceof ViewGroup group) { + // scrollBy with a large value is capped synchronously by RecyclerView's + // LayoutManager to the actual bottom + group.scrollBy(0, 100_000); + + FeatureFlags.isGhostSeen = false; + group.scrollBy(0, -200); + + view.postDelayed(() -> { + group.scrollBy(0, 200); + FeatureFlags.isGhostSeen = true; + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_seen_sent), Toast.LENGTH_SHORT).show(); + }, 300); + } + } catch (Exception e) { + FeatureFlags.isGhostSeen = true; + } + } + + private int dp(Context ctx, int v) { + return (int) (v * ctx.getResources().getDisplayMetrics().density); + } +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/SeenState.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMSeenHook.java similarity index 73% rename from app/src/main/java/ps/reso/instaeclipse/mods/ghost/SeenState.java rename to app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMSeenHook.java index c8083d1a..067f6c7d 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/SeenState.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostDMSeenHook.java @@ -13,14 +13,33 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; /** * Handles Ghost Mode for Direct Messages (DM) in Instagram. */ -public class SeenState { +public class GhostDMSeenHook { public void handleSeenBlock(DexKitBridge bridge) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.isGhostSeen) param.setResult(null); + } + }; + + // Cache hit — skip DexKit + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("GhostSeen", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + XposedBridge.log("(InstaEclipse | GhostModeSeen): ✅ Hooked: " + cached.getDeclaringClass().getName() + "." + cached.getName()); + FeatureStatusTracker.setHooked("GhostSeen"); + return; + } + } + try { // Step 1: Find all methods containing "mark_thread_seen-" List methods = bridge.findMethod(FindMethod.create() @@ -50,20 +69,8 @@ public void handleSeenBlock(DexKitBridge bridge) { && paramTypes.size() >= 3) { try { - XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) { - /* - Debug purposes - XposedBridge.log("(InstaEclipse | GhostModeSeen): 🚫 Blocked seen ping from: " + - method.getClassName() + "." + method.getName()); - */ - // ✅ Only block if GhostSeen is active - if (FeatureFlags.isGhostSeen) { - param.setResult(null); - } - } - }); + DexKitCache.saveMethod("GhostSeen", reflectMethod); + XposedBridge.hookMethod(reflectMethod, hook); XposedBridge.log("(InstaEclipse | GhostModeSeen): ✅ Hooked: " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostEphemeralKeepHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostEphemeralKeepHook.java new file mode 100644 index 00000000..b881becb --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostEphemeralKeepHook.java @@ -0,0 +1,187 @@ +package ps.reso.instaeclipse.mods.ghost; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +/** + * Prevents disappearing/vanish-mode messages from being deleted locally. + * + * Three hooks: + * 1. hookVanishLocalDelete — no-ops the method that drives local deletion of + * vanish/ephemeral messages. Found via "igThreadIgid" + * + (DirectThreadKey, boolean) → void signature. + * 2. hookServerPing — blocks the outgoing mark_ephemeral_item_ranges_viewed + * server call (belt-and-suspenders with the Interceptor). + * 3. hookExpiryParser — zeroes message_expiration_timestamp_ms long fields after + * model parsing so no local countdown timer is started. + */ +public class GhostEphemeralKeepHook { + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + hookVanishLocalDelete(bridge, classLoader); + hookServerPing(bridge); + hookExpiryParser(bridge, classLoader); + } + + /** + * No-ops the method that deletes ephemeral/vanish messages from the local thread model. + * Found via "igThreadIgid" combined with (DirectThreadKey, boolean) → void signature. + */ + private void hookVanishLocalDelete(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.keepEphemeralMessages) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("Ephemeral_vanish", classLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + FeatureStatusTracker.setHooked("KeepEphemeralMessages"); + return; + } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("igThreadIgid") + .paramTypes("com.instagram.model.direct.DirectThreadKey", "boolean") + .returnType("void"))); + + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + DexKitCache.saveMethod("Ephemeral_vanish", m); + XposedBridge.hookMethod(m, hook); + XposedBridge.log("(IE|Ephemeral) ✅ vanish-local-delete hook → " + + md.getClassName() + "." + md.getName()); + FeatureStatusTracker.setHooked("KeepEphemeralMessages"); + return; + } catch (Throwable ignored) {} + } + XposedBridge.log("(IE|Ephemeral) ❌ vanish-local-delete method not found"); + } catch (Throwable t) { + XposedBridge.log("(IE|Ephemeral) ❌ hookVanishLocalDelete: " + t.getMessage()); + } + } + + /** Blocks any void method that dispatches mark_ephemeral_item_ranges_viewed. */ + private void hookServerPing(DexKitBridge bridge) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.keepEphemeralMessages) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("Ephemeral_ping", Module.hostClassLoader); + if (cached != null) { XposedBridge.hookMethod(cached, hook); return; } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("mark_ephemeral_item_ranges_viewed"))); + + for (MethodData md : methods) { + Method method; + try { + method = md.getMethodInstance(Module.hostClassLoader); + } catch (Throwable e) { + continue; + } + if (method.getReturnType() != void.class) continue; + + DexKitCache.saveMethod("Ephemeral_ping", method); + XposedBridge.hookMethod(method, hook); + XposedBridge.log("(IE|Ephemeral) ✅ server-ping hook → " + + md.getClassName() + "." + md.getName()); + return; + } + XposedBridge.log("(IE|Ephemeral) ❌ server-ping method not found"); + } catch (Throwable t) { + XposedBridge.log("(IE|Ephemeral) ❌ hookServerPing: " + t.getMessage()); + } + } + + /** + * Zeroes any long field on parsed model objects whose value looks like a future + * epoch-ms timestamp, so the local expiry countdown never starts. + */ + private void hookExpiryParser(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.keepEphemeralMessages) return; + clearExpiryTimestamp(param.thisObject); + Object result = param.getResult(); + if (result != null && result != param.thisObject) clearExpiryTimestamp(result); + } + }; + + if (DexKitCache.isCacheValid()) { + List cached = DexKitCache.loadMethods("Ephemeral_expiry", classLoader); + if (cached != null && !cached.isEmpty()) { + for (Method m : cached) XposedBridge.hookMethod(m, hook); + return; + } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("message_expiration_timestamp_ms"))); + + List hooked = new java.util.ArrayList<>(); + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + XposedBridge.hookMethod(m, hook); + hooked.add(m); + XposedBridge.log("(IE|Ephemeral) ✅ expiry-parser hook → " + + md.getClassName() + "." + md.getName()); + } catch (Throwable ignored) {} + } + if (hooked.isEmpty()) { + XposedBridge.log("(IE|Ephemeral) ❌ no expiry-parser methods hooked"); + } else { + DexKitCache.saveMethods("Ephemeral_expiry", hooked); + } + } catch (Throwable t) { + XposedBridge.log("(IE|Ephemeral) ❌ hookExpiryParser: " + t.getMessage()); + } + } + + private static void clearExpiryTimestamp(Object obj) { + if (obj == null) return; + long now = System.currentTimeMillis(); + long year2100 = 4_102_444_800_000L; + try { + for (Field f : obj.getClass().getDeclaredFields()) { + if (f.getType() != long.class) continue; + f.setAccessible(true); + long val = f.getLong(obj); + if (val > now && val < year2100) { + f.setLong(obj, 0L); + } + } + } catch (Throwable ignored) {} + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostPermanentViewHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostPermanentViewHook.java new file mode 100644 index 00000000..912afa1e --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostPermanentViewHook.java @@ -0,0 +1,139 @@ +package ps.reso.instaeclipse.mods.ghost; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +/** + * Makes view-once and view-twice (replayable) media behave like permanent media. + * + * Instagram parses the server's JSON "view_mode" field in unsafeParseFromJson + * (class X/1Ui in build 423) and stores it as a plain String on the media model. + * + * Possible values: + * "once" — view once (disappears after one open) + * "replayable" — view twice (one extra replay allowed) + * "permanent" — normal media, always accessible + * + * We hook the parser method after it runs and replace any non-permanent + * view_mode value with "permanent" so Instagram renders the media normally. + * + * DexKit fingerprint: method using both "archived_media_timestamp" AND "view_mode" + * with exactly 1 parameter (the JSON reader). This distinguishes it from the + * companion serializer method in the same class which has 2 parameters. + */ +public class GhostPermanentViewHook { + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("ViewOnceMedia", classLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, buildHook()); + FeatureStatusTracker.setHooked("PermanentViewMode"); + return; + } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("archived_media_timestamp", "view_mode") + .paramCount(1))); + + if (methods.isEmpty()) { + XposedBridge.log("(IE|ViewOnceMedia) ❌ unsafeParseFromJson not found"); + return; + } + + // Pick the method whose return type is not void (the parser returns the model object; + // the serializer returns void). Fall back to the first candidate if none match. + Method target = null; + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + if (m.getReturnType() != void.class) { + target = m; + break; + } + } catch (Throwable ignored) {} + } + if (target == null) { + // Fallback: just use the first found + try { + target = methods.get(0).getMethodInstance(classLoader); + } catch (Throwable t) { + XposedBridge.log("(IE|ViewOnceMedia) ❌ Could not resolve method: " + t); + return; + } + } + + XposedBridge.log("(IE|ViewOnceMedia) ✅ hooking " + + target.getDeclaringClass().getName() + "." + target.getName()); + + DexKitCache.saveMethod("ViewOnceMedia", target); + XposedBridge.hookMethod(target, buildHook()); + + FeatureStatusTracker.setHooked("PermanentViewMode"); + XposedBridge.log("(IE|ViewOnceMedia) ✅ hooked"); + + } catch (Throwable t) { + XposedBridge.log("(IE|ViewOnceMedia) ❌ " + t); + } + } + + private static XC_MethodHook buildHook() { + return new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.permanentViewMode) return; + Object result = param.getResult(); + if (result == null) return; + + // Collect seen_count and all String fields in one pass + int seenCount = 0; + Class cls = result.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (f.getType() == int.class) { + f.setAccessible(true); + try { seenCount = f.getInt(result); } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + + cls = result.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (f.getType() != String.class) continue; + f.setAccessible(true); + try { + String val = (String) f.get(result); + if ("once".equals(val)) { + // seen_count >= 1 means it was already viewed — CDN URL is gone + if (seenCount >= 1) return; + f.set(result, "permanent"); + } else if ("replayable".equals(val) || "allow_replay".equals(val)) { + // replayable allows 2 views; >= 2 means fully consumed + if (seenCount >= 2) return; + f.set(result, "permanent"); + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + }; + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostReplayLimitHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostReplayLimitHook.java new file mode 100644 index 00000000..82690f23 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostReplayLimitHook.java @@ -0,0 +1,180 @@ +package ps.reso.instaeclipse.mods.ghost; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +public class GhostReplayLimitHook { + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + hookUpdateMethod(bridge, classLoader); + hookParseFromJsonMethod(bridge, classLoader); + hookSyncMethod(bridge, classLoader); + } + + /** + * Hooks the DM thread entry update that marks the visual message as seen. + * Skipping it keeps the local "seen" state at 0. + */ + private void hookUpdateMethod(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.enableUnlimitedReplays) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("Replays_update", classLoader); + if (cached != null) { XposedBridge.hookMethod(cached, hook); return; } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("Entry should exist before function call", + "Visual message is missing from thread entry"))); + + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + if (m.getReturnType() != void.class) continue; + DexKitCache.saveMethod("Replays_update", m); + XposedBridge.hookMethod(m, hook); + XposedBridge.log("(IE|Replays) ✅ update hook → " + md.getClassName() + "." + md.getName()); + return; + } catch (Throwable ignored) {} + } + XposedBridge.log("(IE|Replays) ❌ update method not found"); + } catch (Throwable t) { + XposedBridge.log("(IE|Replays) ❌ hookUpdateMethod: " + t); + } + } + + /** + * Hooks parseFromJson that reads "seen_count" and "tap_models" from the server + * response. After it runs, zeroes any small int field on thisObject — those are + * the replay counters; IDs and timestamps are longs and won't match. + */ + private void hookParseFromJsonMethod(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableUnlimitedReplays) return; + zeroReplayCountFields(param.thisObject); + if (param.getResult() != null && param.getResult() != param.thisObject) + zeroReplayCountFields(param.getResult()); + } + }; + + if (DexKitCache.isCacheValid()) { + List cached = DexKitCache.loadMethods("Replays_parse", classLoader); + if (cached != null && !cached.isEmpty()) { + for (Method m : cached) XposedBridge.hookMethod(m, hook); + XposedBridge.log("[IE] ✅ Ghost Replay – parseFromJson"); + return; + } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("seen_count", "tap_models"))); + + List hooked = new ArrayList<>(); + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + XposedBridge.hookMethod(m, hook); + hooked.add(m); + XposedBridge.log("(IE|Replays) ✅ parseFromJson hook → " + md.getClassName() + "." + md.getName()); + } catch (Throwable ignored) {} + } + if (hooked.isEmpty()) { + XposedBridge.log("(IE|Replays) ❌ parseFromJson method not found"); + } else { + DexKitCache.saveMethods("Replays_parse", hooked); + XposedBridge.log("[IE] ✅ Ghost Replay – parseFromJson"); + } + } catch (Throwable t) { + XposedBridge.log("(IE|Replays) ❌ hookParseFromJsonMethod: " + t); + } + } + + /** + * Hooks the synchronized method (UserSession as first param, 3 params total) + * that persists the seen/replay count to local store. Skipping it stops the + * counter from being committed. + */ + private void hookSyncMethod(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.enableUnlimitedReplays) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("Replays_sync", classLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + XposedBridge.log("[IE] ✅ Ghost Replay – sync"); + FeatureStatusTracker.setHooked("UnlimitedReplays"); + return; + } + } + + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .paramTypes("com.instagram.common.session.UserSession", null, null) + .returnType("void"))); + + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + if (!java.lang.reflect.Modifier.isSynchronized(m.getModifiers())) continue; + DexKitCache.saveMethod("Replays_sync", m); + XposedBridge.hookMethod(m, hook); + XposedBridge.log("[IE] ✅ Ghost Replay – sync"); + XposedBridge.log("(IE|Replays) ✅ sync hook → " + md.getClassName() + "." + md.getName()); + FeatureStatusTracker.setHooked("UnlimitedReplays"); + return; + } catch (Throwable ignored) {} + } + XposedBridge.log("(IE|Replays) ❌ sync method not found"); + } catch (Throwable t) { + XposedBridge.log("(IE|Replays) ❌ hookSyncMethod: " + t); + } + } + + /** + * Zeroes int fields whose value is in [1, 10] on the given object. + * Replay/seen counts are always tiny (1 or 2); IDs and timestamps are longs. + */ + private static void zeroReplayCountFields(Object obj) { + if (obj == null) return; + try { + for (Field f : obj.getClass().getDeclaredFields()) { + if (f.getType() != int.class) continue; + f.setAccessible(true); + int val = f.getInt(obj); + if (val >= 1 && val <= 10) { + f.setInt(obj, 0); + } + } + } catch (Throwable ignored) {} + } +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotDetection.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostScreenshotDetectionHook.java similarity index 75% rename from app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotDetection.java rename to app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostScreenshotDetectionHook.java index 8ad0cc47..e25bb00c 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotDetection.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostScreenshotDetectionHook.java @@ -15,12 +15,30 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -public class ScreenshotDetection { +public class GhostScreenshotDetectionHook { public void handleScreenshotBlock(DexKitBridge bridge) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + if (FeatureFlags.isGhostScreenshot) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("GhostScreenshot", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + XposedBridge.log("(InstaEclipse | ScreenshotBlock): ✅ Hooked (dynamic check): " + cached.getDeclaringClass().getName() + "." + cached.getName()); + FeatureStatusTracker.setHooked("GhostScreenshot"); + return; + } + } + try { // Step 1: Find class referencing "ScreenshotNotificationManager" List classes = bridge.findClass(FindClass.create() @@ -49,15 +67,8 @@ public void handleScreenshotBlock(DexKitBridge bridge) { try { Method targetMethod = method.getMethodInstance(Module.hostClassLoader); - - XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - if (FeatureFlags.isGhostScreenshot) { - param.setResult(null); // Block logic - } - } - }); + DexKitCache.saveMethod("GhostScreenshot", targetMethod); + XposedBridge.hookMethod(targetMethod, hook); XposedBridge.log("(InstaEclipse | ScreenshotBlock): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/StorySeen.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostStorySeenHook.java similarity index 72% rename from app/src/main/java/ps/reso/instaeclipse/mods/ghost/StorySeen.java rename to app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostStorySeenHook.java index efd96a95..94f7fe60 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/StorySeen.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostStorySeenHook.java @@ -13,12 +13,30 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -public class StorySeen { +public class GhostStorySeenHook { public void handleStorySeenBlock(DexKitBridge bridge) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.isGhostStory) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("GhostStorySeen", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + XposedBridge.log("(InstaEclipse | StoryBlock): ✅ Hooked (dynamic check): " + cached.getDeclaringClass().getName() + "." + cached.getName()); + FeatureStatusTracker.setHooked("GhostStories"); + return; + } + } + try { // Step 1: Find methods containing the string "media/seen/" List methods = bridge.findMethod(FindMethod.create() @@ -48,14 +66,8 @@ public void handleStorySeenBlock(DexKitBridge bridge) { paramTypes.size() == 0) { try { - XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) { - if (FeatureFlags.isGhostStory) { - param.setResult(null); // Block if GhostStory is enabled - } - } - }); + DexKitCache.saveMethod("GhostStorySeen", reflectMethod); + XposedBridge.hookMethod(reflectMethod, hook); XposedBridge.log("(InstaEclipse | StoryBlock): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/TypingStatus.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostTypingIndicatorHook.java similarity index 73% rename from app/src/main/java/ps/reso/instaeclipse/mods/ghost/TypingStatus.java rename to app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostTypingIndicatorHook.java index 89f43d24..d4a9195a 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/TypingStatus.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostTypingIndicatorHook.java @@ -13,12 +13,30 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -public class TypingStatus { +public class GhostTypingIndicatorHook { public void handleTypingBlock(DexKitBridge bridge) { + XC_MethodHook hook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (FeatureFlags.isGhostTyping) param.setResult(null); + } + }; + + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("GhostTyping", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, hook); + XposedBridge.log("(InstaEclipse | TypingBlock): ✅ Hooked (dynamic check): " + cached.getDeclaringClass().getName() + "." + cached.getName()); + FeatureStatusTracker.setHooked("GhostTyping"); + return; + } + } + try { // Step 1: Find methods containing the string "is_typing_indicator_enabled" List methods = bridge.findMethod(FindMethod.create() @@ -51,16 +69,8 @@ public void handleTypingBlock(DexKitBridge bridge) { String.valueOf(paramTypes.get(1)).contains("boolean")) { try { - // Step 3: Hook method dynamically - XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) { - if (FeatureFlags.isGhostTyping) { - // If ghost typing is enabled, block typing ping - param.setResult(null); - } - } - }); + DexKitCache.saveMethod("GhostTyping", reflectMethod); + XposedBridge.hookMethod(reflectMethod, hook); XposedBridge.log("(InstaEclipse | TypingBlock): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ViewOnce.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostViewOnceHook.java similarity index 55% rename from app/src/main/java/ps/reso/instaeclipse/mods/ghost/ViewOnce.java rename to app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostViewOnceHook.java index bd0e4fd8..d036aec1 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ViewOnce.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/GhostViewOnceHook.java @@ -12,12 +12,23 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -public class ViewOnce { +public class GhostViewOnceHook { public void handleViewOnceBlock(DexKitBridge bridge) { + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("GhostViewOnce", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, buildViewOnceHook()); + XposedBridge.log("(InstaEclipse | ViewOnce): ✅ Hooked (dynamic check): " + cached.getDeclaringClass().getName() + "." + cached.getName()); + FeatureStatusTracker.setHooked("GhostViewOnce"); + return; + } + } + try { // Step 1: Find methods containing "visual_item_seen" List methods = bridge.findMethod( @@ -47,43 +58,8 @@ public void handleViewOnceBlock(DexKitBridge bridge) { } // Step 3: Hook method - XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - if (!FeatureFlags.isGhostViewOnce) { - return; // Feature disabled → skip - } - - Object rw = param.args[2]; // Third argument (visual item object) - if (rw == null) { - return; - } - - for (Method m : rw.getClass().getDeclaredMethods()) { - // Only check methods with no params returning String - if (m.getParameterTypes().length != 0 || m.getReturnType() != String.class) { - continue; - } - - try { - m.setAccessible(true); - String value = (String) m.invoke(rw); - if (value == null) { - continue; - } - - if (value.contains("visual_item_seen") || - value.contains("send_visual_item_seen_marker")) { - // XposedBridge.log("Blocked ViewOnce send: " + value); - param.setResult(null); // Block this call - } - } catch (Throwable ignored) { - // Ignore reflection errors - } - } - } - }); - + DexKitCache.saveMethod("GhostViewOnce", reflectMethod); + XposedBridge.hookMethod(reflectMethod, buildViewOnceHook()); XposedBridge.log("(InstaEclipse | ViewOnce): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); @@ -96,4 +72,26 @@ protected void beforeHookedMethod(MethodHookParam param) throws Throwable { XposedBridge.log("(InstaEclipse | ViewOnce): ❌ Exception: " + e.getMessage()); } } + + private static XC_MethodHook buildViewOnceHook() { + return new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + if (!FeatureFlags.isGhostViewOnce) return; + Object rw = param.args[2]; + if (rw == null) return; + for (Method m : rw.getClass().getDeclaredMethods()) { + if (m.getParameterTypes().length != 0 || m.getReturnType() != String.class) continue; + try { + m.setAccessible(true); + String value = (String) m.invoke(rw); + if (value != null && (value.contains("visual_item_seen") || + value.contains("send_visual_item_seen_marker"))) { + param.setResult(null); + } + } catch (Throwable ignored) {} + } + } + }; + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotPermissionHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotPermissionHook.java new file mode 100644 index 00000000..987c86ba --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ghost/ScreenshotPermissionHook.java @@ -0,0 +1,53 @@ +package ps.reso.instaeclipse.mods.ghost; + +import android.view.Window; +import android.view.WindowManager; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; + +/** + * Strips FLAG_SECURE from every Window.setFlags / Window.addFlags call so the + * user can take screenshots even when Instagram would normally block them. + * + * Instagram sets FLAG_SECURE on several windows (DM threads, stories, reels) + * which causes the system to show "App doesn't allow screenshots". We intercept + * both entry points before the flag reaches the WindowManager so no patching + * of Instagram's internal classes is required. + */ +public class ScreenshotPermissionHook { + + public void install(ClassLoader classLoader) { + try { + // Hook Window.setFlags(int flags, int mask) + XposedHelpers.findAndHookMethod(Window.class, "setFlags", + int.class, int.class, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (!FeatureFlags.allowScreenshots) return; + param.args[0] = (int) param.args[0] & ~WindowManager.LayoutParams.FLAG_SECURE; + param.args[1] = (int) param.args[1] & ~WindowManager.LayoutParams.FLAG_SECURE; + } + }); + + // Hook Window.addFlags(int flags) + XposedHelpers.findAndHookMethod(Window.class, "addFlags", + int.class, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (!FeatureFlags.allowScreenshots) return; + param.args[0] = (int) param.args[0] & ~WindowManager.LayoutParams.FLAG_SECURE; + } + }); + + XposedBridge.log("(InstaEclipse | ScreenshotPermission): ✅ Hooked Window.setFlags + addFlags"); + FeatureStatusTracker.setHooked("AllowScreenshots"); + + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | ScreenshotPermission): ❌ " + e.getMessage()); + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/DownloadSaveService.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/DownloadSaveService.java new file mode 100644 index 00000000..6a2c25f0 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/DownloadSaveService.java @@ -0,0 +1,398 @@ +package ps.reso.instaeclipse.mods.media; + +import android.annotation.SuppressLint; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.media.MediaMuxer; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.widget.Toast; + +import androidx.documentfile.provider.DocumentFile; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.ByteBuffer; + +/** + * Foreground service that runs in the companion-app process and saves downloaded media + * to a SAF (Storage Access Framework) folder. + * + *

The companion app holds the persistable SAF permission for folders picked via + * FeaturesFragment. The Xposed module (running in Instagram's process) cannot use + * that permission directly, so it forwards the CDN URL(s) to this service as plain + * string extras. The service downloads the media itself — no file-descriptor passing + * across different UIDs is required. + * + *

For video+audio-separate streams the service also handles the merge step locally. + * Download progress is reported via a live-updating foreground notification. + */ +public class DownloadSaveService extends Service { + + private static final String CHANNEL_ID = "ie_dl_save"; + private static final int NOTIF_ID = 0x49455344; // "IESD" — ongoing progress + private static final int DONE_NOTIF_BASE = 0x49455345; // per-startId completion notifs + private static final String CACHE_PREFS = "instaeclipse_cache"; + private static final String UA = + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"; + + /** Throttle: minimum ms between notification updates. */ + private static final long NOTIF_INTERVAL_MS = 250; + + private NotificationManager nm; + private long lastNotifMs = 0; + private int lastNotifPct = -1; + + @Override + public void onCreate() { + super.onCreate(); + nm = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + ensureChannel(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + startForeground(NOTIF_ID, buildProgressNotification("Starting…", 0, 0, true)); + + if (intent == null) { stopSelf(startId); return START_NOT_STICKY; } + + String url = intent.getStringExtra("url"); + String audioUrl = intent.getStringExtra("audioUrl"); // null → single-stream + String filename = intent.getStringExtra("filename"); + String mimeType = intent.getStringExtra("mimeType"); + String username = intent.getStringExtra("username"); + + if (url == null || filename == null) { stopSelf(startId); return START_NOT_STICKY; } + + SharedPreferences cache = getSharedPreferences(CACHE_PREFS, Context.MODE_PRIVATE); + String saveUri = cache.getString("downloaderCustomUri", ""); + boolean usernameFolder = cache.getBoolean("downloaderUsernameFolder", false); + + if (saveUri.isEmpty()) { + showToast("InstaEclipse: no download folder set"); + stopSelf(startId); + return START_NOT_STICKY; + } + + final int sid = startId; + final String fUrl = url, fAudio = audioUrl, fFile = filename; + final String fMime = mimeType, fUser = username, fSave = saveUri; + final boolean fUF = usernameFolder; + + new Thread(() -> { + try { + Uri savedUri = fAudio != null + ? downloadMergeAndSave(fUrl, fAudio, fFile, fMime, fSave, fUser, fUF) + : downloadAndSave(fUrl, fFile, fMime, fSave, fUser, fUF); + postDoneNotification(sid, "Saved: " + fFile, fMime, savedUri); + showToast("Saved: " + fFile); + } catch (Throwable e) { + postDoneNotification(sid, "Download failed: " + e.getMessage(), null, null); + showToast("Save failed: " + e.getMessage()); + } finally { + stopSelf(sid); + } + }, "ie-saf-" + startId).start(); + + return START_NOT_STICKY; + } + + // ── Download helpers ────────────────────────────────────────────────────── + + private Uri downloadAndSave(String url, String filename, String mimeType, + String saveUri, String username, boolean usernameFolder) + throws Exception { + File tmp = File.createTempFile("ie_dl_", mimeType.contains("video") ? ".mp4" : ".jpg", + getCacheDir()); + try { + pushProgress("Downloading…", 0, 100, false); + downloadToFile(url, tmp, (done, total) -> { + if (total > 0) { + int pct = (int) (done * 95 / total); + maybeUpdateProgress("Downloading…", pct, 100); + } else { + maybeUpdateProgress("Downloading…", 0, 0, true); + } + }); + pushProgress("Saving…", 97, 100, false); + return writeViaSaf(tmp, filename, mimeType, saveUri, username, usernameFolder); + } finally { + //noinspection ResultOfMethodCallIgnored + tmp.delete(); + } + } + + private Uri downloadMergeAndSave(String videoUrl, String audioUrl, String filename, + String mimeType, String saveUri, + String username, boolean usernameFolder) + throws Exception { + File cacheDir = getCacheDir(); + long ts = System.currentTimeMillis(); + File tv = new File(cacheDir, "ie_sv_" + ts + ".mp4"); + File ta = new File(cacheDir, "ie_sa_" + ts + ".mp4"); + File out = new File(cacheDir, "ie_sm_" + ts + ".mp4"); + try { + // Video download: 0–60 % + pushProgress("Downloading video…", 0, 100, false); + downloadToFile(videoUrl, tv, (done, total) -> { + if (total > 0) { + int pct = (int) (done * 60 / total); + maybeUpdateProgress("Downloading video…", pct, 100); + } + }); + + // Audio download: 60–80 % + pushProgress("Downloading audio…", 60, 100, false); + downloadToFile(audioUrl, ta, (done, total) -> { + if (total > 0) { + int pct = 60 + (int) (done * 20 / total); + maybeUpdateProgress("Downloading audio…", pct, 100); + } + }); + + // Merge: 80–95 % (indeterminate — MediaMuxer gives no callbacks) + pushProgress("Merging…", 80, 100, true); + mergeVideoAudio(tv.getAbsolutePath(), ta.getAbsolutePath(), out.getAbsolutePath()); + + pushProgress("Saving…", 97, 100, false); + return writeViaSaf(out, filename, mimeType, saveUri, username, usernameFolder); + } finally { + //noinspection ResultOfMethodCallIgnored + tv.delete(); + //noinspection ResultOfMethodCallIgnored + ta.delete(); + //noinspection ResultOfMethodCallIgnored + out.delete(); + } + } + + // ── SAF write ──────────────────────────────────────────────────────────── + + private Uri writeViaSaf(File src, String filename, String mimeType, + String saveUri, String username, boolean usernameFolder) + throws Exception { + DocumentFile dir = DocumentFile.fromTreeUri(this, Uri.parse(saveUri)); + if (dir == null || !dir.canWrite()) { + throw new Exception("SAF folder not writable — was the permission revoked?"); + } + + if (usernameFolder && username != null && !username.isEmpty()) { + DocumentFile sub = dir.findFile(username); + if (sub == null || !sub.isDirectory()) sub = dir.createDirectory(username); + if (sub == null) throw new Exception("Cannot create username sub-folder"); + dir = sub; + } + + DocumentFile file = dir.createFile(mimeType, filename); + if (file == null) throw new Exception("SAF createFile returned null"); + + try (FileInputStream in = new FileInputStream(src); + OutputStream os = getContentResolver().openOutputStream(file.getUri())) { + if (os == null) throw new Exception("SAF openOutputStream returned null"); + byte[] buf = new byte[32768]; int n; + while ((n = in.read(buf)) != -1) os.write(buf, 0, n); + } + return file.getUri(); + } + + // ── Network / merge utilities ───────────────────────────────────────────── + + @FunctionalInterface + interface ProgressCallback { + /** @param bytesRead bytes downloaded so far; @param totalBytes -1 if unknown */ + void onProgress(long bytesRead, long totalBytes); + } + + private static void downloadToFile(String url, File dest, ProgressCallback cb) + throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", UA); + conn.connect(); + long total = conn.getContentLengthLong(); // -1 if server doesn't send Content-Length + try (InputStream in = conn.getInputStream(); + FileOutputStream fos = new FileOutputStream(dest)) { + byte[] buf = new byte[32768]; + long downloaded = 0; + int n; + while ((n = in.read(buf)) != -1) { + fos.write(buf, 0, n); + downloaded += n; + if (cb != null) cb.onProgress(downloaded, total); + } + } finally { + conn.disconnect(); + } + } + + private static void mergeVideoAudio(String vp, String ap, String op) throws Exception { + MediaExtractor vEx = new MediaExtractor(), aEx = new MediaExtractor(); + MediaMuxer mux = new MediaMuxer(op, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + try { + vEx.setDataSource(vp); aEx.setDataSource(ap); + int vi = selectTrack(vEx, "video/"), ai = selectTrack(aEx, "audio/"); + if (vi < 0 || ai < 0) throw new Exception("Missing video or audio track"); + int vo = mux.addTrack(vEx.getTrackFormat(vi)); + int ao = mux.addTrack(aEx.getTrackFormat(ai)); + mux.start(); + ByteBuffer buf = ByteBuffer.allocate(1024 * 1024); + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + copyTrack(vEx, mux, vo, buf, info); + copyTrack(aEx, mux, ao, buf, info); + mux.stop(); + } finally { vEx.release(); aEx.release(); mux.release(); } + } + + private static int selectTrack(MediaExtractor ex, String mime) { + for (int i = 0; i < ex.getTrackCount(); i++) { + String m = ex.getTrackFormat(i).getString(MediaFormat.KEY_MIME); + if (m != null && m.startsWith(mime)) { ex.selectTrack(i); return i; } + } + return -1; + } + + @SuppressLint("WrongConstant") + private static void copyTrack(MediaExtractor ex, MediaMuxer mux, int outTrack, + ByteBuffer buf, MediaCodec.BufferInfo info) { + ex.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + while (true) { + int sz = ex.readSampleData(buf, 0); + if (sz < 0) break; + info.offset = 0; info.size = sz; + info.presentationTimeUs = ex.getSampleTime(); + info.flags = ex.getSampleFlags(); + mux.writeSampleData(outTrack, buf, info); + ex.advance(); + } + } + + // ── Notification helpers ────────────────────────────────────────────────── + + /** + * Unconditionally updates the foreground notification (use for phase transitions). + */ + private void pushProgress(String text, int progress, int max, boolean indeterminate) { + lastNotifPct = progress; + lastNotifMs = System.currentTimeMillis(); + nm.notify(NOTIF_ID, buildProgressNotification(text, progress, max, indeterminate)); + } + + /** Convenience overload for determinate progress. */ + private void pushProgress(String text, int progress, int max) { + pushProgress(text, progress, max, false); + } + + /** + * Throttled update — called on every chunk read. Skips the notify call if + * less than {@link #NOTIF_INTERVAL_MS} have passed AND progress changed < 2 %. + */ + private void maybeUpdateProgress(String text, int pct, int max) { + maybeUpdateProgress(text, pct, max, false); + } + + private void maybeUpdateProgress(String text, int pct, int max, boolean indeterminate) { + long now = System.currentTimeMillis(); + if (Math.abs(pct - lastNotifPct) < 2 && now - lastNotifMs < NOTIF_INTERVAL_MS) return; + lastNotifPct = pct; + lastNotifMs = now; + nm.notify(NOTIF_ID, buildProgressNotification(text, pct, max, indeterminate)); + } + + private Notification buildProgressNotification(String text, int progress, int max, + boolean indeterminate) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return new Notification.Builder(this, CHANNEL_ID) + .setContentTitle("InstaEclipse") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setProgress(max, progress, indeterminate) + .setOngoing(true) + .build(); + } + //noinspection deprecation + return new Notification.Builder(this) + .setContentTitle("InstaEclipse") + .setContentText(text) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setProgress(max, progress, indeterminate) + .setOngoing(true) + .build(); + } + + /** + * Posts a non-ongoing completion/error notification that persists after the service stops. + * Uses {@code DONE_NOTIF_BASE + startId} so concurrent downloads don't collide. + */ + /** + * Posts a persistent completion notification. + * If {@code fileUri} is non-null, tapping the notification opens the saved file + * in the device's default viewer (gallery, video player, etc.). + */ + private void postDoneNotification(int startId, String text, String mimeType, Uri fileUri) { + int icon = fileUri != null + ? android.R.drawable.stat_sys_download_done + : android.R.drawable.stat_notify_error; + + // Build a tap-to-open PendingIntent when we have a valid file URI + android.app.PendingIntent contentIntent = null; + if (fileUri != null && mimeType != null) { + Intent viewIntent = new Intent(Intent.ACTION_VIEW); + viewIntent.setDataAndType(fileUri, mimeType); + viewIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_ACTIVITY_NEW_TASK + | Intent.FLAG_ACTIVITY_CLEAR_TOP); + int piFlags = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M + ? android.app.PendingIntent.FLAG_IMMUTABLE | android.app.PendingIntent.FLAG_ONE_SHOT + : android.app.PendingIntent.FLAG_ONE_SHOT; + contentIntent = android.app.PendingIntent.getActivity( + this, DONE_NOTIF_BASE + startId, viewIntent, piFlags); + } + + Notification.Builder builder; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + builder = new Notification.Builder(this, CHANNEL_ID); + } else { + //noinspection deprecation + builder = new Notification.Builder(this); + } + builder.setContentTitle("InstaEclipse") + .setContentText(text) + .setSmallIcon(icon) + .setAutoCancel(true); + if (contentIntent != null) builder.setContentIntent(contentIntent); + nm.notify(DONE_NOTIF_BASE + startId, builder.build()); + } + + private void showToast(String msg) { + // Use application context — service context is invalid after stopSelf() fires + Context appCtx = getApplicationContext(); + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(appCtx, msg, Toast.LENGTH_SHORT).show()); + } + + private void ensureChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel ch = new NotificationChannel( + CHANNEL_ID, "InstaEclipse Downloads", NotificationManager.IMPORTANCE_LOW); + ch.setSound(null, null); + nm.createNotificationChannel(ch); + } + } + + @Override public IBinder onBind(Intent intent) { return null; } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/FeedVideoDownloadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/FeedVideoDownloadHook.java new file mode 100644 index 00000000..18cd41df --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/FeedVideoDownloadHook.java @@ -0,0 +1,2000 @@ +package ps.reso.instaeclipse.mods.media; + +import android.annotation.SuppressLint; +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.ContentValues; +import android.content.Context; +import android.content.res.Configuration; +import android.database.Cursor; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.media.MediaCodec; +import android.media.MediaExtractor; +import android.media.MediaFormat; +import android.media.MediaMuxer; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.os.Handler; +import android.os.Looper; +import android.provider.DocumentsContract; +import android.provider.MediaStore; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.ImageButton; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.RequiresApi; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindClass; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.enums.StringMatchType; +import org.luckypray.dexkit.query.matchers.ClassMatcher; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.ClassData; +import org.luckypray.dexkit.result.MethodData; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Deque; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.WeakHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; +import ps.reso.instaeclipse.utils.users.UserUtils; + +public class FeedVideoDownloadHook { + + private static final String DOWNLOAD_BTN_TAG = "ie_media_download_btn"; + + /** View tag key used by ReelDownloadHook to bind a Media object to the reel like_button. */ + static final int TAG_REEL_MEDIA = "ie_reel_media".hashCode(); + + // ── Class/method refs resolved once at hook install time ───────────────── + private static Class mediaExtKtClass; + private static Class mediaClass; + static Class mutableMediaDictIntfClass; + private static Method methodImageUrl; // MediaExtKt: static (Context, Media) -> String + + // VideoVersionIntf – stable public interface with getUrl() + static Class videoVersionIntfClass; + static Method videoVersionGetUrl; // VideoVersionIntf.getUrl() -> String + + // All () -> List candidates from MutableMediaDictIntf + its superinterfaces + static final List carouselCandidates = new ArrayList<>(); + + // User class + the method on MutableMediaDictIntf that returns it — resolved via DexKit + private static Class userClass; + private static Method dictUserGetter; // () -> UserClass on MutableMediaDictIntf + // userUsernameGetter lives in UserUtils — resolved here and stored there + + // ── Uri.parse fallback buffer ───────────────────────────────────────────── + private static final class UrlEntry { + final String url; final long time; + UrlEntry(String u) { url = u; time = System.currentTimeMillis(); } + } + private static final int MAX_URLS = 200; + private static final Deque urlBuffer = new ArrayDeque<>(); + private static final Deque videoUrlBuffer = new ArrayDeque<>(); // DexKit-captured video URLs + private static final WeakHashMap> buttonUrls = new WeakHashMap<>(); + static final ExecutorService executor = Executors.newCachedThreadPool(); + static final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // Username + media ID resolved at download trigger time + private volatile String currentDownloadUsername = null; + private volatile String currentDownloadMediaId = null; + + // ── Entry point ────────────────────────────────────────────────────────── + + public void install(ClassLoader classLoader) { + // Load Media and MediaExtKt + try { + mediaClass = classLoader.loadClass("com.instagram.feed.media.Media"); + mediaExtKtClass = classLoader.loadClass("com.instagram.feed.media.MediaExtKt"); + // Find static (Context, Media) -> String method (name changes every version) + for (Method m : mediaExtKtClass.getDeclaredMethods()) { + Class[] p = m.getParameterTypes(); + if (p.length == 2 + && "android.content.Context".equals(p[0].getName()) + && p[1] == mediaClass + && m.getReturnType() == String.class) { + m.setAccessible(true); + methodImageUrl = m; + break; + } + } + } catch (Throwable ignored) {} + + // Load VideoVersionIntf (stable public interface with getUrl()) + try { + videoVersionIntfClass = classLoader.loadClass("com.instagram.model.mediasize.VideoVersionIntf"); + videoVersionGetUrl = videoVersionIntfClass.getMethod("getUrl"); + } catch (Throwable ignored) {} + + // Load MutableMediaDictIntf and collect () -> List methods from it + // AND its direct superinterfaces only (Instagram 423+ moved Cz7() to LX/IdM). + // Do NOT recurse deeper — LX/IdM's own ancestors flood us with unrelated methods. + try { + mutableMediaDictIntfClass = classLoader.loadClass("com.instagram.feed.media.MutableMediaDictIntf"); + Set seen = new HashSet<>(); + // Declared methods on MutableMediaDictIntf itself (DIS, BJ4, CjW, ...) + for (Method m : mutableMediaDictIntfClass.getDeclaredMethods()) { + if (m.getParameterCount() == 0 && List.class.isAssignableFrom(m.getReturnType())) { + if (seen.add(m.getName())) { m.setAccessible(true); carouselCandidates.add(m); } + } + } + // Direct superinterfaces only (captures Cz7() from LX/IdM without going deeper) + for (Class superIface : mutableMediaDictIntfClass.getInterfaces()) { + String sn = superIface.getName(); + if (!sn.startsWith("com.instagram.") && !sn.startsWith("com.facebook.") && !sn.startsWith("X.")) continue; + for (Method m : superIface.getDeclaredMethods()) { + if (m.getParameterCount() == 0 && List.class.isAssignableFrom(m.getReturnType())) { + if (seen.add(m.getName())) { m.setAccessible(true); carouselCandidates.add(m); } + } + } + } + } catch (Throwable ignored) {} + + installUriCaptureHook(); + } + + // ── Hook 1: Uri.parse (fallback buffer) ────────────────────────────────── + + private void installUriCaptureHook() { + try { + XposedHelpers.findAndHookMethod(Uri.class, "parse", String.class, + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enablePostDownload) return; + String s = (String) param.args[0]; + if (s == null || !isCdnMediaUrl(s)) return; + synchronized (urlBuffer) { + if (!urlBuffer.isEmpty() && urlBuffer.peekFirst().url.equals(s)) + return; + urlBuffer.addFirst(new UrlEntry(s)); + while (urlBuffer.size() > MAX_URLS) urlBuffer.removeLast(); + } + } + }); + FeatureStatusTracker.setHooked("PostDownload"); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | MediaDownload): ❌ Uri.parse hook: " + t); + } + } + + // ── Hook 2: View.onAttachedToWindow ────────────────────────────────────── + + private void installViewHook() { + try { + XposedHelpers.findAndHookMethod(View.class, "onAttachedToWindow", + new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enablePostDownload) return; + View view = (View) param.thisObject; + Context ctx = view.getContext(); + + @SuppressLint("DiscouragedApi") + int feedLikeId = ctx.getResources().getIdentifier( + "row_feed_button_like", "id", ctx.getPackageName()); + @SuppressLint("DiscouragedApi") + int reelLikeId = ctx.getResources().getIdentifier( + "like_button", "id", ctx.getPackageName()); + @SuppressLint("DiscouragedApi") + int clipsUfiId = ctx.getResources().getIdentifier( + "clips_ufi_component", "id", ctx.getPackageName()); + + int viewId = view.getId(); + boolean isFeedLike = feedLikeId != 0 && viewId == feedLikeId; + boolean isReelLike = reelLikeId != 0 && viewId == reelLikeId + && hasAncestorWithId(view, clipsUfiId); + + if (!isFeedLike && !isReelLike) return; + if (!(view.getParent() instanceof ViewGroup parent)) return; + + long now = System.currentTimeMillis(); + List snapshot = snapshotUrlsSince(now - 10_000); + + if (isFeedLike) { + // Feed post: inject floating download button + View existing = parent.findViewWithTag(DOWNLOAD_BTN_TAG); + if (existing != null) { + synchronized (buttonUrls) { buttonUrls.put(existing, snapshot); } + return; + } + injectDownloadButton(view, parent, ctx, snapshot); + } else { + // Reel: long-press the like button to download. + // ReelDownloadHook tags this view with the Media object via TAG_REEL_MEDIA. + view.setOnLongClickListener(lv -> { + if (!FeatureFlags.enablePostDownload) return false; + Object media = lv.getTag(TAG_REEL_MEDIA); + if (media != null) { + String url = bestVideoUrlFromMedia(media); + if (url != null) { + XposedBridge.log("(IE|Reel) media tag hit, url=" + url); + onDownloadClicked(ctx, List.of(url), lv); + return true; + } + XposedBridge.log("(IE|Reel) media tag set but no video URL found in object"); + } + // Fallback: filter buffer for m86 URLs only (combined stream, one per reel) + List all = snapshotUrlsSince(System.currentTimeMillis() - 60_000); + List m86 = new ArrayList<>(); + for (String u : all) { if (u.contains("/m86/") || u.contains("%2Fm86%2F")) m86.add(u); } + List pick = m86.isEmpty() ? all : m86; + if (pick.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_no_reel_url_scroll), Toast.LENGTH_SHORT).show(); + return true; + } + XposedBridge.log("(IE|Reel) buffer fallback, m86=" + m86.size() + " total=" + all.size()); + // Take only the most recent URL (first in deque = newest) + onDownloadClicked(ctx, List.of(pick.get(0)), lv); + return true; + }); + XposedBridge.log("(IE|Reel) long-press hook set on like_button"); + } + } + }); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | MediaDownload): ❌ View hook: " + t); + } + } + + // ── Button injection ────────────────────────────────────────────────────── + + private void injectDownloadButton(View saveBtn, ViewGroup parent, + Context ctx, List snapshot) { + ImageButton btn = new ImageButton(ctx); + btn.setTag(DOWNLOAD_BTN_TAG); + btn.setImageResource(android.R.drawable.stat_sys_download); + btn.setColorFilter(Color.WHITE); + btn.setBackground(null); + btn.setContentDescription("Download media"); + + int size = dp(ctx, 34); + ViewGroup.LayoutParams lp; + if (parent instanceof LinearLayout) { + LinearLayout.LayoutParams llp = new LinearLayout.LayoutParams(size, size); + llp.gravity = Gravity.CENTER_VERTICAL; + llp.setMargins(dp(ctx, 4), 0, dp(ctx, 4), 0); + lp = llp; + } else { + FrameLayout.LayoutParams flp = new FrameLayout.LayoutParams(size, size); + flp.gravity = Gravity.CENTER_VERTICAL | Gravity.END; + flp.setMargins(0, 0, dp(ctx, 8), 0); + lp = flp; + } + btn.setLayoutParams(lp); + synchronized (buttonUrls) { buttonUrls.put(btn, snapshot); } + + btn.setOnClickListener(v -> { + List urls = resolveUrls(saveBtn, v); + if (urls.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_no_media_for_post), Toast.LENGTH_SHORT).show(); + return; + } + onDownloadClicked(ctx, urls, saveBtn); + }); + + // Long-press the like button as fallback download trigger. + // This is the primary path when LithoViews prevents button injection. + saveBtn.setOnLongClickListener(lv -> { + if (!FeatureFlags.enablePostDownload) return false; + List urls = resolveUrls(saveBtn, btn); + if (urls.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_no_media), Toast.LENGTH_SHORT).show(); + return true; + } + onDownloadClicked(ctx, urls, saveBtn); + return true; + }); + + parent.post(() -> { + try { + parent.addView(btn); + btn.bringToFront(); + } catch (Exception e) { + XposedBridge.log("(IE|DL) Cannot inject download button: " + e.getMessage()); + } + }); + } + + // ── URL resolution — three-tier ─────────────────────────────────────────── + // + // Tier 1: Reflect on the save button's click listener to find the exact Media + // object captured in its closure. Extract video URL via VideoVersionIntf.getUrl() + // or image URL via MediaExtKt helper. This is per-post with no timing ambiguity. + // + // Tier 2: buttonUrls snapshot taken when row_feed_button_save attached. + // + // Tier 3: Last 30 s of the Uri.parse buffer (catches lazy-loaded carousels). + + @SuppressLint("DiscouragedApi") + private List resolveUrls(View likeBtn, View downloadBtn) { + // Tier-1a: like button's listener (works for standard feed posts) + List urls = urlsFromSaveBtnListener(likeBtn); + XposedBridge.log("(IE|DL) Tier-1a urls=" + urls.size()); + if (!urls.isEmpty()) return urls; + + // Tier-1b: bookmark/save button's listener. + // The save button always captures the Media object (it needs it for save-to-collection). + // IMPORTANT: row_feed_button_save is NOT a sibling of the like button — it sits in + // the action bar parent (one level above the left-buttons group). Walk up up to 4 + // parent levels so we reach the action bar container and find it there. + Context ctx = likeBtn.getContext(); + int saveResId = ctx.getResources().getIdentifier( + "row_feed_button_save", "id", ctx.getPackageName()); + if (saveResId != 0) { + android.view.ViewParent p = likeBtn.getParent(); + for (int i = 0; i < 4 && p instanceof ViewGroup vg; i++, p = vg.getParent()) { + View realSaveBtn = vg.findViewById(saveResId); + if (realSaveBtn != null) { + XposedBridge.log("(IE|DL) Tier-1b found save btn at parent level " + i); + urls = urlsFromSaveBtnListener(realSaveBtn); + XposedBridge.log("(IE|DL) Tier-1b urls=" + urls.size()); + if (!urls.isEmpty()) return urls; + break; // found the button but listener had no URLs — no point going wider + } + } + } + + return new ArrayList<>(); + } + + // ── Tier 1: Save-button listener search ─────────────────────────────────── + // + // Strategy: + // 1. Get the OnClickListener set by Instagram on the save button. + // 2. Find the captured Media object in its closure (depth-limited field scan). + // 3. From the MutableMediaDictIntf on the Media object: + // a. Check if any () -> List candidate returns VideoVersionIntf items + // → single video post: extract URL via getUrl(), return it. + // b. Check if any () -> List candidate returns >= 2 non-video items + // → carousel: try to extract per-item URLs. + // c. Fall back to MediaExtKt image URL helper for single photo posts. + + private static List urlsFromSaveBtnListener(View saveBtn) { + try { + Object listener = getOnClickListener(saveBtn); + if (listener == null) return new ArrayList<>(); + + // Broad CDN URL scan of the listener's object graph (for plain String fields) + List urls = new ArrayList<>(); + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + scanForCdnUrls(listener, urls, 0, visited); + + if (mediaClass != null) { + Object media = findFieldOfType(listener, mediaClass, 4); + + if (media != null) { + // ── Step A: Video detection ──────────────────────────────── + // Two sub-passes for robustness: + // A1 – field-graph scan (fast, works when Pando cache is populated) + // A2 – method invocation on carouselCandidates (reaches JNI-backed data + // that isn't exposed as a Java field until DIS() is called) + String videoUrl = findVideoUrlInObject(media, + Collections.newSetFromMap(new IdentityHashMap<>()), 0); + XposedBridge.log("(IE|DL) stepA1 videoUrl=" + (videoUrl != null + ? videoUrl.substring(0, Math.min(80, videoUrl.length())) : "null")); + + if (videoUrl == null && mutableMediaDictIntfClass != null && !carouselCandidates.isEmpty()) { + // A2: invoke every () -> List method; any that returns VideoVersionIntf items + // is the video-versions list. Size >= 1 is enough (single video post). + Object dictIntf = findFieldAssignableTo(media, mutableMediaDictIntfClass); + if (dictIntf != null && videoVersionIntfClass != null && videoVersionGetUrl != null) { + outer: + for (Method candidate : carouselCandidates) { + try { + Object listObj = candidate.invoke(dictIntf); + if (!(listObj instanceof List items) || items.isEmpty()) continue; + if (!videoVersionIntfClass.isInstance(items.get(0))) continue; + for (Object item : items) { + if (!videoVersionIntfClass.isInstance(item)) continue; + try { + String u = (String) videoVersionGetUrl.invoke(item); + if (u != null && isCdnMediaUrl(u)) { videoUrl = u; break outer; } + } catch (Throwable ignored) {} + } + } catch (Throwable ignored) {} + } + } + XposedBridge.log("(IE|DL) stepA2 videoUrl=" + (videoUrl != null + ? videoUrl.substring(0, Math.min(80, videoUrl.length())) : "null")); + } + if (videoUrl != null) return List.of(videoUrl); + + // ── Step B: Carousel detection ───────────────────────────── + // Try every () -> List method on MutableMediaDictIntf (and its direct + // superinterfaces) to find the carousel item list. + if (mutableMediaDictIntfClass != null && !carouselCandidates.isEmpty()) { + Object dictIntf = findFieldAssignableTo(media, mutableMediaDictIntfClass); + XposedBridge.log("(IE|DL) dictIntf=" + (dictIntf != null + ? dictIntf.getClass().getName() : "null")); + + if (dictIntf != null) { + for (Method candidate : carouselCandidates) { + try { + Object listObj = candidate.invoke(dictIntf); + if (!(listObj instanceof List items) || items.size() < 2) continue; + // Skip VideoVersionIntf lists — already handled in Step A + if (videoVersionIntfClass != null && !items.isEmpty() + && videoVersionIntfClass.isInstance(items.get(0))) continue; + + XposedBridge.log("(IE|Car) candidate=" + candidate.getName() + + " items=" + items.size()); + List carouselUrls = new ArrayList<>(); + + for (int idx = 0; idx < items.size(); idx++) { + Object item = items.get(idx); + if (item == null) continue; + + // 1. If item is a video carousel item — get its video URL + String itemVideo = findVideoUrlInObject(item, + Collections.newSetFromMap(new IdentityHashMap<>()), 0); + if (itemVideo != null) { carouselUrls.add(itemVideo); continue; } + + // 2. Try MediaExtKt helper — works when items are Media objects + // (piko shows newer Instagram carousel items are Media objects) + if (methodImageUrl != null) { + try { + Object r = methodImageUrl.invoke(null, saveBtn.getContext(), item); + if (r instanceof String s && isCdnMediaUrl(s)) { + XposedBridge.log("(IE|Car) item[" + idx + "] mediaExtKt=" + s.substring(0, Math.min(60, s.length()))); + carouselUrls.add(s); + continue; + } + } catch (Throwable ignored) {} + } + + // 3. Probe all no-param String methods (Pando JNI nodes: LX/VPC, LX/5q9) + String probed = probeCdnUrlViaStringMethods(item); + XposedBridge.log("(IE|Car) item[" + idx + "] probed=" + probed); + if (probed != null) { carouselUrls.add(probed); continue; } + + // 4. Generic CDN field scan as last resort + List scanned = new ArrayList<>(); + scanForCdnUrls(item, scanned, 0, + Collections.newSetFromMap(new IdentityHashMap<>())); + if (!scanned.isEmpty()) carouselUrls.add(pickBestImageUrl(scanned)); + } + + XposedBridge.log("(IE|Car) carouselUrls=" + carouselUrls.size()); + if (carouselUrls.size() >= 2) return carouselUrls; + } catch (Throwable ignored) {} + } + } + } + + // ── Step C: Single photo ─────────────────────────────────── + if (methodImageUrl != null) { + try { + Object img = methodImageUrl.invoke(null, saveBtn.getContext(), media); + if (img instanceof String s && isCdnMediaUrl(s)) + return List.of(s); + } catch (Throwable ignored) {} + } + } + + // Fallback: prefer non-video URLs found by the object graph scan + List images = new ArrayList<>(); + for (String u : urls) { if (!isVideoUrl(u)) images.add(u); } + if (!images.isEmpty()) return List.of(pickBestImageUrl(images)); + } + + return urls; + } catch (Throwable t) { + return new ArrayList<>(); + } + } + + /** + * Probes all no-parameter String-returning methods on {@code obj} (including superclass + * declared methods) and returns the first one that yields an Instagram CDN URL. + * + * This is needed for Pando/LiveTree JNI nodes (LX/VPC carousel items, LX/5q9) whose + * image URLs are only accessible via obfuscated JNI-backed methods, not via fields. + */ + private static String probeCdnUrlViaStringMethods(Object obj) { + if (obj == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + String cn = cls.getName(); + if (!cn.startsWith("X.") && !cn.startsWith("com.instagram.") && !cn.startsWith("com.facebook.")) break; + for (Method m : cls.getDeclaredMethods()) { + if (m.getParameterCount() != 0 || m.getReturnType() != String.class) continue; + try { + m.setAccessible(true); + Object r = m.invoke(obj); + if (r instanceof String s && isCdnMediaUrl(s)) return s; + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } + + /** + * Depth-limited field-graph scan for any VideoVersionIntf instance inside {@code obj}. + * Returns the first CDN URL found via {@code VideoVersionIntf.getUrl()}, or null. + * + * This is the primary video-detection path. It is version-independent: it does not + * depend on knowing the obfuscated name of the method that returns the video-version + * list (DIS(), or whatever it is renamed to in newer Instagram builds). + */ + static String findVideoUrlInObject(Object obj, Set visited, int depth) { + if (obj == null || depth > 5 || !visited.add(obj)) return null; + if (videoVersionIntfClass == null || videoVersionGetUrl == null) return null; + + // Direct hit: obj itself implements VideoVersionIntf + if (videoVersionIntfClass.isInstance(obj)) { + try { + String url = (String) videoVersionGetUrl.invoke(obj); + if (url != null && isCdnMediaUrl(url)) return url; + } catch (Throwable ignored) {} + } + + Class cls = obj.getClass(); + String cn = cls.getName(); + if (!cn.startsWith("X.") && !cn.startsWith("com.instagram.") && !cn.startsWith("com.facebook.")) return null; + + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + + if (val instanceof List list) { + // List field — check if any element is a VideoVersionIntf + for (Object elem : list) { + if (elem != null && videoVersionIntfClass.isInstance(elem)) { + try { + String url = (String) videoVersionGetUrl.invoke(elem); + if (url != null && isCdnMediaUrl(url)) return url; + } catch (Throwable ignored) {} + } + } + } else { + // Recurse into Instagram/Facebook objects only + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || vcn.startsWith("com.instagram.") + || vcn.startsWith("com.facebook.")) { + String found = findVideoUrlInObject(val, visited, depth + 1); + if (found != null) return found; + } + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } + + /** + * Collects ALL CDN video URLs found by walking the VideoVersionIntf graph inside {@code obj}. + * Prefers m86 URLs (combined audio+video stream) — those are sorted to the front of the list. + */ + static void collectAllVideoUrls(Object obj, List out, Set visited, int depth) { + if (obj == null || depth > 5 || !visited.add(obj)) return; + if (videoVersionIntfClass == null || videoVersionGetUrl == null) return; + + if (videoVersionIntfClass.isInstance(obj)) { + try { + String url = (String) videoVersionGetUrl.invoke(obj); + if (url != null && isCdnMediaUrl(url) && !out.contains(url)) out.add(url); + } catch (Throwable ignored) {} + return; // don't recurse into VideoVersionIntf objects + } + + Class cls = obj.getClass(); + String cn = cls.getName(); + if (!cn.startsWith("X.") && !cn.startsWith("com.instagram.") && !cn.startsWith("com.facebook.")) return; + + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + if (val instanceof List list) { + for (Object elem : list) + collectAllVideoUrls(elem, out, visited, depth + 1); + } else { + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || vcn.startsWith("com.instagram.") + || vcn.startsWith("com.facebook.")) + collectAllVideoUrls(val, out, visited, depth + 1); + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + + /** Returns the best video URL from the media object: prefers m86 (combined stream). */ + static String bestVideoUrlFromMedia(Object media) { + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + List all = new ArrayList<>(); + collectAllVideoUrls(media, all, visited, 0); + if (all.isEmpty()) return null; + for (String u : all) { if (u.contains("/m86/") || u.contains("%2Fm86%2F")) return u; } + return all.get(0); // fallback: first found + } + + /** + * Tries to call getUrl() on an object if it's available (handles VideoVersionIntf + * and any other object that exposes a stable getUrl() method). + */ + private static String tryGetUrl(Object obj) { + if (obj == null) return null; + try { + Method m = obj.getClass().getMethod("getUrl"); + Object result = m.invoke(obj); + return result instanceof String ? (String) result : null; + } catch (Throwable ignored) { + return null; + } + } + + /** Among multiple resolutions of the same image, prefer the full-size original. */ + private static String pickBestImageUrl(List images) { + for (String url : images) { + if (!url.contains("/s150x") && !url.contains("/s240x") && + !url.contains("/s320x") && !url.contains("/s480x") && + !url.contains("/s640x") && !url.contains("_s.jpg")) { + return url; + } + } + return images.get(0); + } + + /** Reads View.mListenerInfo.mOnClickListener via reflection. */ + private static Object getOnClickListener(View view) { + try { + Field liField = View.class.getDeclaredField("mListenerInfo"); + liField.setAccessible(true); + Object li = liField.get(view); + if (li == null) return null; + Field clField = li.getClass().getDeclaredField("mOnClickListener"); + clField.setAccessible(true); + return clField.get(li); + } catch (Throwable t) { + return null; + } + } + + /** + * Recursively scans an object's fields for Instagram CDN URL strings. + * Only descends into X.* / com.instagram.* / com.facebook.* objects. + */ + private static final int MAX_SCAN_DEPTH = 6; + private static final int MAX_SCAN_URLS = 20; + + private static void scanForCdnUrls(Object obj, List out, + int depth, Set visited) { + if (obj == null || depth > MAX_SCAN_DEPTH || out.size() >= MAX_SCAN_URLS) return; + if (!visited.add(obj)) return; + + Class cls = obj.getClass(); + String cn = cls.getName(); + if (cn.startsWith("android.") || cn.startsWith("java.lang.") || + cn.startsWith("java.util.concurrent.") || cn.startsWith("kotlin.")) return; + + // Also try getUrl() for Pando tree nodes that expose it via method (not field) + String directUrl = tryGetUrl(obj); + if (directUrl != null && isCdnMediaUrl(directUrl) && !out.contains(directUrl)) + out.add(directUrl); + + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + + if (val instanceof String s) { + if (isCdnMediaUrl(s) && !out.contains(s)) out.add(s); + } else if (val instanceof List list) { + for (Object item : list) scanForCdnUrls(item, out, depth + 1, visited); + } else if (val instanceof Object[] arr) { + for (Object item : arr) scanForCdnUrls(item, out, depth + 1, visited); + } else { + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || + vcn.startsWith("com.instagram.") || + vcn.startsWith("com.facebook.")) { + scanForCdnUrls(val, out, depth + 1, visited); + } + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + + private static Object findFieldOfType(Object obj, Class target, int depth) { + if (obj == null || target == null || depth < 0) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (target.isAssignableFrom(f.getType())) { + f.setAccessible(true); + try { return f.get(obj); } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + if (depth > 0) { + cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v == null) continue; + String vcn = v.getClass().getName(); + if (!vcn.startsWith("X.") && !vcn.startsWith("com.instagram.") && + !vcn.startsWith("com.facebook.")) continue; + Object r = findFieldOfType(v, target, depth - 1); + if (r != null) return r; + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + return null; + } + + /** + * Finds the first field on {@code obj} whose declared type is assignable to + * {@code targetType}. Used to locate interface-typed fields. + */ + static Object findFieldAssignableTo(Object obj, Class targetType) { + if (obj == null || targetType == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (targetType.isAssignableFrom(f.getType())) { + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v != null) return v; + } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } + + // ── Buffer helpers ──────────────────────────────────────────────────────── + + private static List snapshotUrlsSince(long from) { + List r = new ArrayList<>(); + synchronized (urlBuffer) { + for (UrlEntry e : urlBuffer) { + if (e.time >= from) r.add(e.url); + else break; + } + } + return r; + } + + private static List snapshotVideoUrlsSince(long from) { + List r = new ArrayList<>(); + synchronized (videoUrlBuffer) { + for (UrlEntry e : videoUrlBuffer) { + if (e.time >= from) r.add(e.url); + else break; + } + } + return r; + } + + /** + * DexKit-based hook on {@code VideoVersionIntf.getUrl()} — installed once at startup. + * + * Finds all concrete classes implementing VideoVersionIntf at runtime using DexKit, + * hooks their {@code getUrl()} method, and passively captures returned CDN URLs into + * {@code videoUrlBuffer}. This is version-proof: it doesn't depend on knowing the + * obfuscated method name that returns the video-versions list (DIS(), etc.). + * + * Used as a supplement to the Uri.parse buffer (Tier 3) when Tiers 1 and 2 fail. + */ + public static void installVideoUrlCaptureHook(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook urlHook = new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enablePostDownload) return; + Object result = param.getResult(); + if (!(result instanceof String url)) return; + if (!isCdnMediaUrl(url)) return; + synchronized (videoUrlBuffer) { + if (!videoUrlBuffer.isEmpty() && videoUrlBuffer.peekFirst().url.equals(url)) return; + videoUrlBuffer.addFirst(new UrlEntry(url)); + while (videoUrlBuffer.size() > MAX_URLS) videoUrlBuffer.removeLast(); + } + } + }; + + // Cache hit: hook all previously-found getUrl() implementations directly + if (DexKitCache.isCacheValid()) { + List cached = DexKitCache.loadMethods("VideoUrlCapture", classLoader); + if (cached != null && !cached.isEmpty()) { + for (Method m : cached) XposedBridge.hookMethod(m, urlHook); + XposedBridge.log("(IE|DL|DexKit) VideoUrlCapture: " + cached.size() + " method(s) from cache"); + resolveUsernameGetter(bridge, classLoader); + return; + } + } + + try { + List classes = bridge.findClass(FindClass.create() + .matcher(ClassMatcher.create() + .addInterface("com.instagram.model.mediasize.VideoVersionIntf", + StringMatchType.Equals, false))); + + XposedBridge.log("(IE|DL|DexKit) VideoVersionIntf implementors found: " + classes.size()); + + List hooked = new ArrayList<>(); + for (ClassData classData : classes) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .declaredClass(classData.getName()) + .name("getUrl") + .returnType("java.lang.String") + .paramCount(0))); + + for (MethodData methodData : methods) { + try { + Method m = methodData.getMethodInstance(classLoader); + XposedBridge.hookMethod(m, urlHook); + XposedBridge.log("(IE|DL|DexKit) ✅ Hooked getUrl() on " + + classData.getName()); + hooked.add(m); + } catch (Throwable e) { + XposedBridge.log("(IE|DL|DexKit) ❌ Hook failed for " + + classData.getName() + ": " + e.getMessage()); + } + } + } catch (Throwable e) { + XposedBridge.log("(IE|DL|DexKit) ❌ findMethod failed for " + + classData.getName() + ": " + e.getMessage()); + } + } + if (!hooked.isEmpty()) DexKitCache.saveMethods("VideoUrlCapture", hooked); + } catch (Throwable e) { + XposedBridge.log("(IE|DL|DexKit) ❌ installVideoUrlCaptureHook: " + e.getMessage()); + } + + resolveUsernameGetter(bridge, classLoader); + } + + /** + * Uses DexKit to find the user class (via "username_missing_during_update") and then + * locates the no-arg method on MutableMediaDictIntf (or its superinterfaces) that + * returns an instance of that class. This gives us a stable way to get the post author + * from the LiveTreeMediaDict without guessing obfuscated method names. + */ + private static void resolveUsernameGetter(DexKitBridge bridge, ClassLoader classLoader) { + // Cache hit: restore userClass and userUsernameGetter without DexKit + if (DexKitCache.isCacheValid()) { + String cachedClassName = DexKitCache.loadString("UserClass"); + Method cachedGetter = DexKitCache.loadMethod("UsernameGetter", classLoader); + if (cachedClassName != null) { + try { + userClass = classLoader.loadClass(cachedClassName); + if (cachedGetter != null) { + UserUtils.userUsernameGetter = cachedGetter; + } + // dictUserGetter is pure-reflection — fall through to scan below + resolveDictUserGetter(classLoader); + return; + } catch (Throwable ignored) {} + } + } + + try { + // Step 1: find the user class via the stable validation string + List userMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("username_missing_during_update"))); + + if (userMethods.isEmpty()) { + XposedBridge.log("(IE|DL|Username) ❌ username_missing_during_update not found"); + return; + } + + userClass = userMethods.get(0).getMethodInstance(classLoader).getDeclaringClass(); + DexKitCache.saveString("UserClass", userClass.getName()); + XposedBridge.log("(IE|DL|Username) userClass=" + userClass.getName()); + + // Resolve the username getter on User via the stable GraphQL field ID -265713450. + try { + List ugMethods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .declaredClass("com.instagram.user.model.User") + .returnType("java.lang.String") + .paramCount(0) + .usingNumbers(-265713450))); + if (!ugMethods.isEmpty()) { + UserUtils.userUsernameGetter = ugMethods.get(0).getMethodInstance(classLoader); + UserUtils.userUsernameGetter.setAccessible(true); + DexKitCache.saveMethod("UsernameGetter", UserUtils.userUsernameGetter); + XposedBridge.log("(IE|DL|Username) userUsernameGetter=" + UserUtils.userUsernameGetter.getName()); + } else { + XposedBridge.log("(IE|DL|Username) ❌ userUsernameGetter not found via -265713450"); + } + } catch (Throwable t) { + XposedBridge.log("(IE|DL|Username) ❌ userUsernameGetter resolution: " + t); + } + + resolveDictUserGetter(classLoader); + + } catch (Throwable t) { + XposedBridge.log("(IE|DL|Username) ❌ resolveUsernameGetter: " + t); + } + } + + private static void resolveDictUserGetter(ClassLoader classLoader) { + if (mutableMediaDictIntfClass == null || userClass == null) return; + + // Use a Breadth-First Search to find the getter in the interface hierarchy + // Instagram 423+ often hides this in a parent interface like X.IdM + Deque> queue = new ArrayDeque<>(); + Set> visited = new HashSet<>(); + queue.add(mutableMediaDictIntfClass); + + while (!queue.isEmpty()) { + Class curr = queue.poll(); + if (curr == null || !visited.add(curr)) continue; + + for (Method m : curr.getDeclaredMethods()) { + // We are looking for the method that returns the User class + // we found via "username_missing_during_update" + if (m.getParameterCount() == 0 && m.getReturnType().equals(userClass)) { + m.setAccessible(true); + dictUserGetter = m; + XposedBridge.log("(IE|DL|Username) ✅ Resolved dictUserGetter: " + m.getName()); + return; + } + } + // Add parent interfaces to the queue + Collections.addAll(queue, curr.getInterfaces()); + } + XposedBridge.log("(IE|DL|Username) ❌ Failed to resolve dictUserGetter in hierarchy"); + } + + // ── Download dispatch ───────────────────────────────────────────────────── + + /** + * Resolves the post author's username by scanning the media object already captured + * in the save/like button's click listener closure. + * Strategy: like button listener → if no media, walk up to save button → then scan + * the media object graph (depth ≤ 2) for an object with getUsername(). + */ + @SuppressLint("DiscouragedApi") + private String getUsernameFromView(View likeBtn) { + if (likeBtn == null || mediaClass == null) return null; + + Object media = getMediaFromListener(getOnClickListener(likeBtn)); + + // Fallback to save button if like button listener is empty + if (media == null) { + Context ctx = likeBtn.getContext(); + int saveResId = ctx.getResources().getIdentifier("row_feed_button_save", "id", ctx.getPackageName()); + if (saveResId != 0) { + android.view.ViewParent p = likeBtn.getParent(); + for (int i = 0; i < 4 && p instanceof ViewGroup vg; i++, p = vg.getParent()) { + View saveBtn = vg.findViewById(saveResId); + if (saveBtn != null) { + media = getMediaFromListener(getOnClickListener(saveBtn)); + if (media != null) break; + } + } + } + } + + if (media == null) return null; + + // TIER 1: Use the resolved Dictionary Getter + if (dictUserGetter != null && mutableMediaDictIntfClass != null) { + try { + Object dictIntf = findFieldAssignableTo(media, mutableMediaDictIntfClass); + if (dictIntf != null) { + Object userObj = dictUserGetter.invoke(dictIntf); + if (userObj != null) { + String name = UserUtils.callUsernameGetter(userObj); + if (name != null) return name; + } + } + } catch (Throwable ignored) {} + } + + // TIER 2: Direct Class Bridge (Best for newer LiveTree versions) + // If we can't find the dictionary, search the Media object for ANY field + // that matches the User class directly. + Object userObj = findFieldOfType(media, userClass, 3); + if (userObj != null) { + String name = UserUtils.callUsernameGetter(userObj); + if (name != null) return name; + } + + // TIER 3: Last resort recursive scan + return scanObjectForUsername(media, 0, Collections.newSetFromMap(new IdentityHashMap<>())); + } + + private Object getMediaFromListener(Object listener) { + if (listener == null || mediaClass == null) return null; + return findFieldOfType(listener, mediaClass, 4); + } + + /** Extracts the short media ID (first segment of the Instagram ID) from the view's media object. */ + @SuppressLint("DiscouragedApi") + private String getMediaIdFromView(View likeBtn) { + if (likeBtn == null || mediaClass == null) return null; + try { + Object media = getMediaFromListener(getOnClickListener(likeBtn)); + if (media == null) { + Context ctx = likeBtn.getContext(); + int saveResId = ctx.getResources().getIdentifier("row_feed_button_save", "id", ctx.getPackageName()); + if (saveResId != 0) { + android.view.ViewParent p = likeBtn.getParent(); + for (int i = 0; i < 4 && p instanceof ViewGroup vg; i++, p = vg.getParent()) { + View saveBtn = vg.findViewById(saveResId); + if (saveBtn != null) { + media = getMediaFromListener(getOnClickListener(saveBtn)); + if (media != null) break; + } + } + } + } + if (media == null) return null; + Object id = media.getClass().getMethod("getId").invoke(media); + if (id instanceof String s && !s.isEmpty()) return s.split("_")[0]; + } catch (Throwable ignored) {} + return null; + } + + // ── Filename + directory helpers (package-accessible for StoryDownloadHook) ── + + static String buildFilename(String username, String type, String mediaId, boolean isVideo) { + String u = (username != null && !username.isEmpty()) ? username : "unknown"; + String id = (mediaId != null && !mediaId.isEmpty()) ? mediaId : String.valueOf(System.currentTimeMillis()); + String ext = isVideo ? ".mp4" : ".jpg"; + StringBuilder sb = new StringBuilder(u).append('_').append(type).append('_').append(id); + if (FeatureFlags.downloaderAddTimestamp) { + sb.append('_').append(new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date())); + } + return sb.append(ext).toString(); + } + + /** + * Opens a writable OutputStream for the download destination, handling all storage strategies: + * 1. Raw file path (custom folder, avoids SAF authority issues when URI was granted to companion app) + * 2. SAF tree URI (works when folder was picked from inside Instagram's own dialog) + * 3. MediaStore Downloads (API 29+, default scoped-storage path) + * 4. Legacy direct file (API < 29) + */ + static OutputStream openOutputStream(Context ctx, String filename, boolean isVideo, String username) + throws Exception { + String mimeType = isVideo ? "video/mp4" : "image/jpeg"; + + // 1. Raw path — preferred when set; bypasses SAF authority entirely + if (!FeatureFlags.downloaderCustomPath.isEmpty()) { + try { + return openRawPathOutputStream(filename, username); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse|DL) Raw path failed, trying SAF: " + e.getMessage()); + } + } + + // 2. SAF — only works when the folder was picked inside Instagram's process + // (so Instagram holds the persistable URI permission, not the companion app) + if (!FeatureFlags.downloaderCustomUri.isEmpty()) { + try { + return openSafOutputStream(ctx, filename, mimeType, username); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse|DL) SAF failed, falling back to MediaStore: " + e.getMessage()); + } + } + + // 3. MediaStore (API 29+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return openMediaStoreOutputStream(ctx, filename, mimeType, username); + } + + // 4. Legacy API < 29: direct file write + File dir = new File(Environment.getExternalStorageDirectory(), "InstaEclipse"); + if (FeatureFlags.downloaderUsernameFolder && username != null && !username.isEmpty()) { + dir = new File(dir, username); + } + //noinspection ResultOfMethodCallIgnored + dir.mkdirs(); + return new FileOutputStream(new File(dir, filename)); + } + + private static OutputStream openRawPathOutputStream(String filename, String username) throws Exception { + String rawPath = FeatureFlags.downloaderCustomPath; + // Reject if path conversion failed and we got a content URI string as fallback + if (rawPath.startsWith("content://")) { + throw new Exception("Not a raw file path: " + rawPath); + } + File dir = new File(rawPath); + if (FeatureFlags.downloaderUsernameFolder && username != null && !username.isEmpty()) { + dir = new File(dir, username); + } + if (!dir.exists() && !dir.mkdirs()) { + throw new Exception("Cannot create dir: " + dir.getAbsolutePath()); + } + return new FileOutputStream(new File(dir, filename)); + } + + private static OutputStream openSafOutputStream(Context ctx, String filename, String mimeType, String username) + throws Exception { + Uri treeUri = Uri.parse(FeatureFlags.downloaderCustomUri); + String rootDocId = DocumentsContract.getTreeDocumentId(treeUri); + Uri dirUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, rootDocId); + if (FeatureFlags.downloaderUsernameFolder && username != null && !username.isEmpty()) { + dirUri = findOrCreateSafDir(ctx, treeUri, rootDocId, username); + } + Uri fileUri = DocumentsContract.createDocument(ctx.getContentResolver(), dirUri, mimeType, filename); + if (fileUri == null) throw new Exception("SAF createDocument returned null"); + OutputStream out = ctx.getContentResolver().openOutputStream(fileUri); + if (out == null) throw new Exception("SAF openOutputStream returned null"); + return out; + } + + private static Uri findOrCreateSafDir(Context ctx, Uri treeUri, String parentDocId, String dirName) + throws Exception { + Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(treeUri, parentDocId); + try (Cursor c = ctx.getContentResolver().query(childrenUri, + new String[]{DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME}, + null, null, null)) { + while (c != null && c.moveToNext()) { + if (dirName.equals(c.getString(1))) { + return DocumentsContract.buildDocumentUriUsingTree(treeUri, c.getString(0)); + } + } + } + Uri parentUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, parentDocId); + Uri newDir = DocumentsContract.createDocument(ctx.getContentResolver(), parentUri, + DocumentsContract.Document.MIME_TYPE_DIR, dirName); + if (newDir == null) throw new Exception("SAF createDocument (dir) returned null"); + return newDir; + } + + @RequiresApi(Build.VERSION_CODES.Q) + @SuppressLint("NewApi") + private static OutputStream openMediaStoreOutputStream(Context ctx, String filename, String mimeType, String username) + throws Exception { + String relPath = buildMediaStoreRelPath(username); + ContentValues values = new ContentValues(); + values.put(MediaStore.MediaColumns.DISPLAY_NAME, filename); + values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType); + values.put(MediaStore.MediaColumns.RELATIVE_PATH, relPath); + Uri collection = MediaStore.Downloads.EXTERNAL_CONTENT_URI; + Uri itemUri = ctx.getContentResolver().insert(collection, values); + if (itemUri == null) throw new Exception("MediaStore insert failed"); + OutputStream out = ctx.getContentResolver().openOutputStream(itemUri); + if (out == null) throw new Exception("MediaStore openOutputStream returned null"); + return out; + } + + // Standard top-level directories that MediaStore.Downloads accepts as RELATIVE_PATH roots + private static final java.util.Set MS_ROOTS = new java.util.HashSet<>(java.util.Arrays.asList( + "Download", "Downloads", "Pictures", "DCIM", "Movies", "Music", + "Ringtones", "Alarms", "Notifications", "Podcasts", "Audiobooks")); + + /** + * Derives the MediaStore RELATIVE_PATH for the download. + * - If the custom path falls under a known MediaStore root (Download, Pictures, …), + * it is used directly (e.g. Pictures/IG). + * - Otherwise the path is nested under Download/ (e.g. /sdcard/Test55 → Download/Test55). + * - Falls back to Download/InstaEclipse when no custom path is set. + */ + private static String buildMediaStoreRelPath(String username) { + String customPath = FeatureFlags.downloaderCustomPath; + String base = "Download/InstaEclipse"; // default + + if (!customPath.isEmpty() && !customPath.startsWith("content://")) { + String extBase = Environment.getExternalStorageDirectory().getAbsolutePath(); + if (customPath.startsWith(extBase + "/")) { + String relative = customPath.substring(extBase.length() + 1); // e.g. "Test55" or "Pictures/IG" + String topLevel = relative.split("/")[0]; + base = MS_ROOTS.contains(topLevel) ? relative : ("Download/" + relative); + } + } + + if (FeatureFlags.downloaderUsernameFolder && username != null && !username.isEmpty()) { + base += "/" + username; + } + return base; + } + + /** Copies tempFile to the download destination (only used when no custom SAF URI is set). */ + static void saveFileToDestination(Context ctx, File tempFile, String filename, + boolean isVideo, String username) throws Exception { + try (FileInputStream in = new FileInputStream(tempFile); + OutputStream out = openOutputStream(ctx, filename, isVideo, username)) { + byte[] buf = new byte[32768]; int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } + } + + /** + * Reads the companion app's latest SAF URI from its shared prefs WITHOUT overwriting + * FeatureFlags — callers decide what to do with the value. + */ + private static String readCompanionUri() { + try { + de.robv.android.xposed.XSharedPreferences cp = + new de.robv.android.xposed.XSharedPreferences( + "ps.reso.instaeclipse", "instaeclipse_cache"); + cp.reload(); + return cp.getString("downloaderCustomUri", ""); + } catch (Throwable t) { + return ""; + } + } + + /** + * Downloads {@code url} and saves it with the configured destination. + * + * When a custom SAF URI is configured, the CDN URL is forwarded to + * {@link DownloadSaveService} in the companion-app process — it holds the SAF + * permission (granted when the user picked the folder in FeaturesFragment) and writes + * the file directly. No file-descriptor passing across UIDs is required. + * + * @return {@code true} when delegated (async — service shows its own toast). + */ + static boolean downloadAndSave(Context ctx, String url, String filename, + boolean isVideo, String username) throws Exception { + // Prefer FeatureFlags (live value synced from companion via broadcast). + // Fall back to reading companion cache directly (missed-broadcast / cold-start case). + String uri = FeatureFlags.downloaderCustomUri.isEmpty() + ? readCompanionUri() + : FeatureFlags.downloaderCustomUri; + + if (!uri.isEmpty()) { + delegateUrlToCompanionApp(ctx, url, null, filename, isVideo, username); + return true; + } + + // No custom folder configured → MediaStore / raw path. + try (OutputStream out = openOutputStream(ctx, filename, isVideo, username)) { + downloadToStream(url, out); + } + return false; + } + + /** + * Starts {@link DownloadSaveService} in the companion-app process, passing the CDN + * URL(s) as plain string extras — no file descriptors cross the process boundary. + * The service downloads the media itself and writes to the SAF folder it already owns. + * + * @param audioUrl non-null to request a video+audio merge inside the service + */ + private static void delegateUrlToCompanionApp(Context ctx, + String url, + String audioUrl, + String filename, boolean isVideo, + String username) throws Exception { + android.content.Intent intent = new android.content.Intent(); + intent.setClassName("ps.reso.instaeclipse", + "ps.reso.instaeclipse.mods.media.DownloadSaveService"); + intent.putExtra("url", url); + if (audioUrl != null) intent.putExtra("audioUrl", audioUrl); + intent.putExtra("filename", filename); + intent.putExtra("mimeType", isVideo ? "video/mp4" : "image/jpeg"); + intent.putExtra("username", username); + ctx.startForegroundService(intent); + XposedBridge.log("(IE|DL) Delegated to DownloadSaveService: " + filename); + } + + /** + * Package-accessible: collects Instagram CDN media URLs from the given object graph. + * Used by PostDownloadContextMenuHook as a fallback URL source. + */ + static List collectCdnUrls(Object obj) { + List out = new ArrayList<>(); + scanForCdnUrls(obj, out, 0, Collections.newSetFromMap(new IdentityHashMap<>())); + return out; + } + + /** + * Package-accessible: extracts the image URL from a Media object using MediaExtKt helper. + * Returns null if not available (e.g. MediaExtKt not resolved or media is a video-only post). + */ + static String imageUrlFromMedia(Context ctx, Object media) { + if (methodImageUrl == null || ctx == null || media == null) return null; + try { + Object r = methodImageUrl.invoke(null, ctx, media); + return (r instanceof String s && isCdnMediaUrl(s)) ? s : null; + } catch (Throwable ignored) { + return null; + } + } + + /** + * Package-accessible: extracts all downloadable URLs from a Media object. + * Returns a single-entry list for plain photo/video posts, multi-entry for carousels. + * Steps: (A) video, (B) carousel via MutableMediaDictIntf, (C) single photo, (D) CDN scan. + */ + static List extractAllUrlsFromMedia(Context ctx, Object media) { + if (media == null) return new ArrayList<>(); + + // Step A: single video + String videoUrl = bestVideoUrlFromMedia(media); + if (videoUrl != null) return new ArrayList<>(List.of(videoUrl)); + + // Step B: carousel (MutableMediaDictIntf candidates) + if (mutableMediaDictIntfClass != null && !carouselCandidates.isEmpty()) { + Object dictIntf = findFieldAssignableTo(media, mutableMediaDictIntfClass); + if (dictIntf != null) { + for (Method candidate : carouselCandidates) { + try { + Object listObj = candidate.invoke(dictIntf); + if (!(listObj instanceof List items) || items.size() < 2) continue; + if (videoVersionIntfClass != null && !items.isEmpty() + && videoVersionIntfClass.isInstance(items.get(0))) continue; + + List carouselUrls = new ArrayList<>(); + for (int idx = 0; idx < items.size(); idx++) { + Object item = items.get(idx); + if (item == null) continue; + String itemVideo = bestVideoUrlFromMedia(item); + if (itemVideo != null) { carouselUrls.add(itemVideo); continue; } + if (methodImageUrl != null && ctx != null) { + try { + Object r = methodImageUrl.invoke(null, ctx, item); + if (r instanceof String s && isCdnMediaUrl(s)) { + carouselUrls.add(s); continue; + } + } catch (Throwable ignored) {} + } + String probed = probeCdnUrlViaStringMethods(item); + if (probed != null) { carouselUrls.add(probed); continue; } + List scanned = new ArrayList<>(); + scanForCdnUrls(item, scanned, 0, Collections.newSetFromMap(new IdentityHashMap<>())); + if (!scanned.isEmpty()) carouselUrls.add(pickBestImageUrl(scanned)); + } + if (carouselUrls.size() >= 2) return carouselUrls; + } catch (Throwable ignored) {} + } + } + } + + // Step C: single photo + String imageUrl = imageUrlFromMedia(ctx, media); + if (imageUrl != null) return new ArrayList<>(List.of(imageUrl)); + + // Step D: CDN scan fallback + List cdnUrls = collectCdnUrls(media); + if (!cdnUrls.isEmpty()) return new ArrayList<>(List.of(cdnUrls.get(0))); + + return new ArrayList<>(); + } + + /** + * Package-accessible: shows download dialog for a post. + * Single URL → direct download. Multiple (carousel) → "Download current / Download all" dialog. + * currentIndex = the visible carousel slide (from findCarouselIndex). Must be called on main thread. + */ + @SuppressLint("DefaultLocale") + static void showPostDownloadDialog(Context ctx, List urls, + String username, String mediaId, int currentIndex) { + if (urls.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_post_url_not_found), Toast.LENGTH_SHORT).show(); + return; + } + if (urls.size() == 1) { + String url = urls.get(0); + boolean isVid = isVideoUrl(url); + String fn = buildFilename(username, "post", mediaId, isVid); + Toast.makeText(ctx, isVid ? I18n.t(ctx, R.string.ig_toast_downloading_video) : I18n.t(ctx, R.string.ig_toast_downloading_photo), Toast.LENGTH_SHORT).show(); + executor.submit(() -> { + try { + boolean delegated = downloadAndSave(ctx, url, fn, isVid, username); + if (!delegated) { + mainHandler.post(() -> Toast.makeText(ctx, + isVid ? I18n.t(ctx, R.string.ig_toast_video_saved) : I18n.t(ctx, R.string.ig_toast_photo_saved), + Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + XposedBridge.log("(IE|Post|DL) single failed: " + e); + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_download_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }); + return; + } + + // Carousel: modern bottom sheet with two pill buttons + int n = urls.size(); + int safeIdx = (currentIndex >= 0 && currentIndex < n) ? currentIndex : 0; + showCarouselBottomSheet(ctx, urls, username, mediaId, n, safeIdx); + } + + // ── Modern bottom sheet for carousel download ───────────────────────────── + + private static boolean isDarkTheme(Context ctx) { + return (ctx.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + private static GradientDrawable roundRect(int color, float radiusDp, Context ctx) { + float r = radiusDp * ctx.getResources().getDisplayMetrics().density; + GradientDrawable d = new GradientDrawable(); + d.setColor(color); + d.setCornerRadius(r); + return d; + } + + private static Button makePillButton(Context ctx, String label, + int bgColor, int textColor, float dp) { + Button btn = new Button(ctx); + btn.setText(label); + btn.setTextColor(textColor); + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + btn.setTypeface(null, Typeface.BOLD); + btn.setBackground(roundRect(bgColor, 14, ctx)); + btn.setAllCaps(false); + btn.setPadding((int)(20 * dp), (int)(14 * dp), (int)(20 * dp), (int)(14 * dp)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + lp.topMargin = (int)(10 * dp); + btn.setLayoutParams(lp); + return btn; + } + + private static void showCarouselBottomSheet(Context ctx, List urls, + String username, String mediaId, + int n, int safeIdx) { + try { + float dp = ctx.getResources().getDisplayMetrics().density; + boolean dk = isDarkTheme(ctx); + + int sheetBg = dk ? Color.parseColor("#1C1C1E") : Color.parseColor("#F2F2F7"); + int textPrim = dk ? Color.WHITE : Color.parseColor("#1C1C1E"); + int textSec = dk ? Color.parseColor("#AEAEB2") : Color.parseColor("#6C6C70"); + int accentBg = Color.parseColor("#0A84FF"); + int secondBg = dk ? Color.parseColor("#3A3A3C") : Color.parseColor("#E5E5EA"); + int secondText = dk ? Color.WHITE : Color.parseColor("#1C1C1E"); + int handleClr = dk ? Color.parseColor("#48484A") : Color.parseColor("#C7C7CC"); + + LinearLayout sheet = new LinearLayout(ctx); + sheet.setOrientation(LinearLayout.VERTICAL); + sheet.setBackground(roundRect(sheetBg, 20, ctx)); + int hPad = (int)(20 * dp); + sheet.setPadding(hPad, (int)(12 * dp), hPad, (int)(28 * dp)); + + // Drag handle + View handle = new View(ctx); + LinearLayout.LayoutParams handleLp = new LinearLayout.LayoutParams( + (int)(40 * dp), (int)(4 * dp)); + handleLp.gravity = Gravity.CENTER_HORIZONTAL; + handleLp.bottomMargin = (int)(16 * dp); + handle.setLayoutParams(handleLp); + handle.setBackground(roundRect(handleClr, 2, ctx)); + sheet.addView(handle); + + // Title + TextView title = new TextView(ctx); + title.setText(I18n.t(ctx, R.string.ig_dl_title)); + title.setTextColor(textPrim); + title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + title.setTypeface(null, Typeface.BOLD); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + titleLp.bottomMargin = (int)(4 * dp); + title.setLayoutParams(titleLp); + sheet.addView(title); + + // Subtitle + TextView subtitle = new TextView(ctx); + subtitle.setText(I18n.t(ctx, R.string.ig_dl_carousel_subtitle, n)); + subtitle.setTextColor(textSec); + subtitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); + LinearLayout.LayoutParams subLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + subLp.bottomMargin = (int)(14 * dp); + subtitle.setLayoutParams(subLp); + sheet.addView(subtitle); + + Dialog dialog = new Dialog(ctx); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + + // Button: Download current + String currentLabel = I18n.t(ctx, R.string.ig_dl_carousel_current, safeIdx + 1, n); + Button btnCurrent = makePillButton(ctx, currentLabel, accentBg, Color.WHITE, dp); + btnCurrent.setOnClickListener(v -> { + dialog.dismiss(); + String url = urls.get(safeIdx); + boolean isVid = isVideoUrl(url); + String fn = buildFilename(username, "post", mediaId, isVid); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_downloading), Toast.LENGTH_SHORT).show(); + executor.submit(() -> { + try { + boolean delegated = downloadAndSave(ctx, url, fn, isVid, username); + if (!delegated) { + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_saved), Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_download_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }); + }); + sheet.addView(btnCurrent); + + // Button: Download all + Button btnAll = makePillButton(ctx, I18n.t(ctx, R.string.ig_dl_carousel_all, n), + secondBg, secondText, dp); + btnAll.setOnClickListener(v -> { + dialog.dismiss(); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_downloading_all_n_items, n), Toast.LENGTH_SHORT).show(); + executor.submit(() -> { + int failed = 0; + for (String url : urls) { + boolean isVid = isVideoUrl(url); + String fn = buildFilename(username, "post", mediaId, isVid); + try { + downloadAndSave(ctx, url, fn, isVid, username); + } catch (Throwable e) { + failed++; + XposedBridge.log("(IE|Post|DL) item failed: " + e); + } + } + final int finalFailed = failed; + mainHandler.post(() -> { + if (finalFailed == 0) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_all_items_saved, n), + Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_items_partial_saved, + n - finalFailed, n, finalFailed), Toast.LENGTH_SHORT).show(); + } + }); + }); + }); + sheet.addView(btnAll); + + dialog.setContentView(sheet); + Window w = dialog.getWindow(); + if (w != null) { + w.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + w.setGravity(Gravity.BOTTOM); + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT); + WindowManager.LayoutParams wlp = w.getAttributes(); + int margin = (int)(12 * dp); + wlp.x = margin; + wlp.y = margin; + w.setAttributes(wlp); + } + dialog.show(); + + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ showCarouselBottomSheet: " + t); + } + } + + /** + * Package-accessible: extracts username from a com.instagram.feed.media.Media object + * using the DexKit-resolved dictUserGetter. Used by StoryDownloadHook. + */ + static String extractUsernameFromMediaObject(Object media) { + if (media == null || dictUserGetter == null || mutableMediaDictIntfClass == null) return null; + try { + Object dictIntf = findFieldAssignableTo(media, mutableMediaDictIntfClass); + if (dictIntf == null) return null; + Object user = dictUserGetter.invoke(dictIntf); + return UserUtils.callUsernameGetter(user); + } catch (Throwable ignored) {} + return null; + } + + /** @deprecated Use {@link UserUtils#callUsernameGetter(Object)} directly. */ + @Deprecated + public static String callUsernameGetter(Object user) { + return UserUtils.callUsernameGetter(user); + } + + /** + * Walks the object graph up to depth 3 looking for any object that has a + * no-arg getUsername() method returning a valid Instagram username string. + * At depth 0 (the Media object itself), logs all field names + types to + * help diagnose where the user object is nested. + */ + private static String scanObjectForUsername(Object obj, int depth, + Set visited) { + if (obj == null || depth > 3 || visited.contains(obj)) return null; + visited.add(obj); + + // Try getUsername() on this object directly + try { + Object result = obj.getClass().getMethod("getUsername").invoke(obj); + if (result instanceof String s && !s.isEmpty() && s.matches("[a-zA-Z0-9._]{1,30}")) { + return s; + } + } catch (Throwable ignored) {} + + if (depth >= 3) return null; + + // Scan all non-primitive, non-String, non-array fields — no class filter, + // rely on depth limit + visited set to prevent runaway recursion + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + Class ft = f.getType(); + if (ft.isPrimitive() || ft == String.class || ft.isArray()) continue; + f.setAccessible(true); + try { + Object val = f.get(obj); + if (val == null) continue; + String u = scanObjectForUsername(val, depth + 1, visited); + if (u != null) return u; + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } + + private void onDownloadClicked(Context ctx, List urls, View saveBtn) { + currentDownloadUsername = getUsernameFromView(saveBtn); + currentDownloadMediaId = getMediaIdFromView(saveBtn); + XposedBridge.log("(IE|DL) onDownloadClicked username=" + currentDownloadUsername + " mediaId=" + currentDownloadMediaId); + List videos = new ArrayList<>(); + List images = new ArrayList<>(); + for (String url : urls) { + if (isVideoUrl(url)) videos.add(url); + else images.add(url); + } + XposedBridge.log("(IE|DL) total=" + urls.size() + + " videos=" + videos.size() + " images=" + images.size()); + for (int i = 0; i < videos.size(); i++) + XposedBridge.log("(IE|DL) video[" + i + "]=" + videos.get(i)); + for (int i = 0; i < images.size(); i++) + XposedBridge.log("(IE|DL) image[" + i + "]=" + images.get(i)); + + if (!videos.isEmpty() && !images.isEmpty()) { + handleMixedContent(ctx, urls, videos, images, saveBtn); + } else if (!videos.isEmpty()) { + handleVideoDownload(ctx, videos, saveBtn); + } else if (images.size() > 1) { + showCarouselDialog(ctx, images, saveBtn); + } else if (!images.isEmpty()) { + startDirectDownload(ctx, images.get(0), false); + } + } + + private void handleMixedContent(Context ctx, List allUrls, + List videos, List images, View saveBtn) { + executor.submit(() -> { + String videoUrl = videos.get(0); + TrackInfo t = probeUrl(videoUrl); + XposedBridge.log("(IE|DL) probeUrl=" + videoUrl + + " hasVideo=" + t.hasVideo + " hasAudio=" + t.hasAudio); + mainHandler.post(() -> { + if (!t.hasVideo && t.hasAudio) { + // Audio-only background track — download the image instead + startDirectDownload(ctx, images.get(0), false); + } else { + // Real video mixed with images — show carousel dialog for all items + showCarouselDialog(ctx, allUrls, saveBtn); + } + }); + }); + } + + private void handleVideoDownload(Context ctx, List videos, View saveBtn) { + if (videos.size() == 1) { + startDirectDownload(ctx, videos.get(0), true); + return; + } + // Multiple video URLs → video carousel, show selection dialog immediately. + // (DASH streams only ever produce a single URL via our Step-A resolver; + // multiple URLs always come from Step-B carousel item extraction.) + showCarouselDialog(ctx, videos, saveBtn); + } + + private void showCarouselDialog(Context ctx, List urls, View saveBtn) { + int idx = saveBtn != null ? findCarouselPosition(saveBtn) : 0; + if (idx >= urls.size()) idx = 0; + final int current = idx; + int n = urls.size(); + new AlertDialog.Builder(ctx) + .setTitle(I18n.t(ctx, R.string.ig_dl_title)) + .setItems(new CharSequence[]{ + I18n.t(ctx, R.string.ig_dl_carousel_current, current + 1, n), + I18n.t(ctx, R.string.ig_dl_carousel_all, n) + }, (d, w) -> { + if (w == 0) { + String url = urls.get(current); + startDirectDownload(ctx, url, isVideoUrl(url)); + } else { + for (String u : urls) startDirectDownload(ctx, u, isVideoUrl(u)); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_downloading_n_items, n), Toast.LENGTH_SHORT).show(); + } + }).show(); + } + + private static int findCarouselPosition(View anchor) { + View container = anchor; + for (int i = 0; i < 8 && container.getParent() instanceof View; i++) { + container = (View) container.getParent(); + } + if (!(container instanceof ViewGroup vg)) return 0; + int pos = searchForPager(vg, 0); + return pos >= 0 ? pos : 0; + } + + private static int searchForPager(ViewGroup group, int depth) { + if (depth > 8) return -1; + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + for (String methodName : new String[]{"getCurrentItem", "getCurrentDataIndex"}) { + try { + Method m = child.getClass().getMethod(methodName); + Object r = m.invoke(child); + if (r instanceof Integer val && val >= 0) return val; + } catch (Throwable ignored) {} + } + if (child instanceof ViewGroup vg) { + int r = searchForPager(vg, depth + 1); + if (r >= 0) return r; + } + } + return -1; + } + + private void startDirectDownload(Context ctx, String url, boolean isVideo) { + String fn = buildFilename(currentDownloadUsername, "post", currentDownloadMediaId, isVideo); + XposedBridge.log("(IE|DL) startDirectDownload file=" + fn); + Toast.makeText(ctx, isVideo ? I18n.t(ctx, R.string.ig_toast_downloading_video) : I18n.t(ctx, R.string.ig_toast_downloading_photo), Toast.LENGTH_SHORT).show(); + executor.submit(() -> { + try { + boolean delegated = downloadAndSave(ctx, url, fn, isVideo, currentDownloadUsername); + if (!delegated) { + mainHandler.post(() -> Toast.makeText(ctx, + isVideo ? I18n.t(ctx, R.string.ig_toast_video_saved) : I18n.t(ctx, R.string.ig_toast_photo_saved), + Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + XposedBridge.log("(IE|DL) download failed: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_download_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }); + } + + private void downloadAndMerge(Context ctx, String videoUrl, String audioUrl) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_merging_video_audio), Toast.LENGTH_SHORT).show(); + executor.submit(() -> { + // Companion always holds the SAF permission — delegate whenever a URI is set. + String uri = FeatureFlags.downloaderCustomUri.isEmpty() + ? readCompanionUri() + : FeatureFlags.downloaderCustomUri; + + if (!uri.isEmpty()) { + String fn = buildFilename(currentDownloadUsername, "post", currentDownloadMediaId, true); + try { + delegateUrlToCompanionApp(ctx, videoUrl, audioUrl, fn, true, currentDownloadUsername); + } catch (Throwable e) { + XposedBridge.log("(IE|DL) merge delegate failed: " + e.getMessage()); + mainHandler.post(() -> startDirectDownload(ctx, videoUrl, true)); + } + return; + } + + // No custom folder — merge locally and save via openOutputStream. + File tv = null, ta = null, merged = null; + try { + File cache = ctx.getCacheDir(); + long ts = System.currentTimeMillis(); + tv = new File(cache, "ie_v_" + ts + ".mp4"); + ta = new File(cache, "ie_a_" + ts + ".mp4"); + merged = new File(cache, "ie_m_" + ts + ".mp4"); + downloadToFile(videoUrl, tv); + downloadToFile(audioUrl, ta); + String fn = buildFilename(currentDownloadUsername, "post", currentDownloadMediaId, true); + mergeVideoAudio(tv.getAbsolutePath(), ta.getAbsolutePath(), merged.getAbsolutePath()); + saveFileToDestination(ctx, merged, fn, true, currentDownloadUsername); + mainHandler.post(() -> Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_video_saved), + Toast.LENGTH_SHORT).show()); + } catch (Throwable e) { + mainHandler.post(() -> startDirectDownload(ctx, videoUrl, true)); + } finally { + if (tv != null) //noinspection ResultOfMethodCallIgnored + tv.delete(); + if (ta != null) //noinspection ResultOfMethodCallIgnored + ta.delete(); + if (merged != null) //noinspection ResultOfMethodCallIgnored + merged.delete(); + } + }); + } + + private static void downloadToFile(String url, File dest) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"); + conn.connect(); + try (InputStream in = conn.getInputStream(); FileOutputStream fos = new FileOutputStream(dest)) { + byte[] buf = new byte[32768]; int n; + while ((n = in.read(buf)) != -1) fos.write(buf, 0, n); + } finally { conn.disconnect(); } + } + + static void downloadToStream(String url, OutputStream out) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"); + conn.connect(); + try (InputStream in = conn.getInputStream()) { + byte[] buf = new byte[32768]; int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } finally { conn.disconnect(); } + } + + private static void mergeVideoAudio(String vp, String ap, String op) throws Exception { + MediaExtractor vEx = new MediaExtractor(), aEx = new MediaExtractor(); + MediaMuxer mux = new MediaMuxer(op, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4); + try { + vEx.setDataSource(vp); aEx.setDataSource(ap); + int vi = selectTrack(vEx, "video/"), ai = selectTrack(aEx, "audio/"); + if (vi < 0 || ai < 0) throw new Exception("Missing tracks"); + int vo = mux.addTrack(vEx.getTrackFormat(vi)), ao = mux.addTrack(aEx.getTrackFormat(ai)); + mux.start(); + ByteBuffer buf = ByteBuffer.allocate(1024 * 1024); + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + copyTrack(vEx, mux, vo, buf, info); copyTrack(aEx, mux, ao, buf, info); + mux.stop(); + } finally { vEx.release(); aEx.release(); mux.release(); } + } + + private static int selectTrack(MediaExtractor ex, String mime) { + for (int i = 0; i < ex.getTrackCount(); i++) { + String m = ex.getTrackFormat(i).getString(MediaFormat.KEY_MIME); + if (m != null && m.startsWith(mime)) { ex.selectTrack(i); return i; } + } + return -1; + } + + @SuppressLint("WrongConstant") + private static void copyTrack(MediaExtractor ex, MediaMuxer mux, int out, + ByteBuffer buf, MediaCodec.BufferInfo info) { + ex.seekTo(0, MediaExtractor.SEEK_TO_CLOSEST_SYNC); + while (true) { + int sz = ex.readSampleData(buf, 0); + if (sz < 0) break; + info.offset = 0; info.size = sz; + info.presentationTimeUs = ex.getSampleTime(); + info.flags = ex.getSampleFlags(); + mux.writeSampleData(out, buf, info); + ex.advance(); + } + } + + private static TrackInfo probeUrl(String url) { + MediaExtractor ex = new MediaExtractor(); + boolean hv = false, ha = false; + try { + ex.setDataSource(url); + for (int i = 0; i < ex.getTrackCount(); i++) { + String m = ex.getTrackFormat(i).getString(MediaFormat.KEY_MIME); + if (m == null) continue; + if (m.startsWith("video/")) hv = true; + if (m.startsWith("audio/")) ha = true; + } + } catch (Throwable ignored) { } finally { ex.release(); } + return new TrackInfo(hv, ha); + } + + private static final class TrackInfo { + final boolean hasVideo, hasAudio; + TrackInfo(boolean v, boolean a) { hasVideo = v; hasAudio = a; } + } + + /** + * Returns true if this CDN URL points to an Instagram feed media item + * (photo or video) — not a profile picture, UI asset, or other non-media content. + * + * Key CDN path segments: + * t51.2885-15 = feed photo (INCLUDE) + * t51.2885-19 = profile picture (EXCLUDE) + * t50.2886-16 = feed video (INCLUDE) + * t51.39750 = exclude (story thumbnails / non-feed content) + */ + static boolean isCdnMediaUrl(String url) { + if (!url.startsWith("http://") && !url.startsWith("https://")) return false; + if (!url.contains("cdninstagram.com") && !url.contains("fbcdn.net")) return false; + // Exclude profile pictures: the t51 CDN path always uses suffix -19 for avatars + // regardless of the bucket number (t51.2885-19, t51.82787-19, etc.) + // Pattern: /t51.-19/ + if (url.contains("/t51.") && url.contains("-19/")) return false; + // Exclude other known non-feed content + if (url.contains("t51.39750")) return false; + return true; + } + + /** + * Returns true if this CDN URL is a video (not a still image or audio-only track). + * + * Instagram CDN naming convention: + * t50.xxxx = all video CDN path segments (t50.2886-16, t50.29441-2, t50.16800-16, etc.) + * t51.xxxx = image content + * /o1/ = Reels/Clips video (path may omit t50 segment) + * + * Known audio-only (exclude): + * /o1/v/t2/ = background music track for Reels + */ + static boolean isVideoUrl(String url) { + // All Instagram video CDN path segments begin with t50. + // Covers all variants: t50.2886-16, t50.29441-2, t50.16800-16, etc. + if (url.contains("t50.")) return true; + // Reels/Clips CDN paths use /o1/ regardless of whether they carry a t50 segment. + // Note: /o1/v/t2/ is NOT audio-only — it is the standard Reels progressive MP4 path. + if (url.contains("/o1/")) return true; + return false; + } + + private static boolean hasAncestorWithId(View view, int targetId) { + if (targetId == 0) return false; + android.view.ViewParent p = view.getParent(); + for (int i = 0; i < 6 && p instanceof View v; i++, p = v.getParent()) { + if (v.getId() == targetId) return true; + } + return false; + } + + private static int dp(Context ctx, int v) { + return (int) (v * ctx.getResources().getDisplayMetrics().density); + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/PostDownloadContextMenuHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/PostDownloadContextMenuHook.java new file mode 100644 index 00000000..4aad1ab1 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/PostDownloadContextMenuHook.java @@ -0,0 +1,479 @@ +package ps.reso.instaeclipse.mods.media; + +import android.app.AndroidAppHelper; +import android.content.Context; +import android.widget.Toast; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindClass; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.ClassMatcher; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.ClassData; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.WeakHashMap; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; + +/** + * Injects a "Download" entry into the feed-post three-dots (⋮) menu. + * + * Hook A — static add-button method on MediaOptionsOverflowMenuCreator + * Found via DexKit: findClass("MediaOptionsOverflowMenuCreator") → findMethod(declaredClass, void). + * Hooks every addButton call; injects our Download entry once per menu popup. + * + * Hook B — options click handler + * Found via DexKit: void method with sole param MediaOption$Option (stable, unobfuscated type). + * Fires on every option tap; we handle DOWNLOAD and trigger the download. + * + * Carousel index: read from the first int field on thisObject whose value fits [0, urlCount). + * Tries a known field name first (fast path), then falls back to scanning all int fields + * so a rename in a future build doesn't break it. + */ +public class PostDownloadContextMenuHook { + + + + // ── Resolved at install time ────────────────────────────────────────────── + + // com.instagram.feed.media.mediaoption.MediaOption$Option — stable public enum + private static Class mediaOptionEnumClass; + private static Object downloadOptionValue; // MediaOption$Option.DOWNLOAD + + // Obfuscated creator class found via "MediaOptionsOverflowMenuCreator" string + private static Class menuCreatorClass; + + // Static "add one button to list" method on menuCreatorClass — no hardcoded name + private static Method addButtonMethod; + private static Object enumNormalValue; + + // Param indices — resolved once when addButtonMethod is found + private static int idxEnum = 0; + private static int idxOption = 1; + private static int idxSelf = 2; + private static int idxText = 3; + private static int idxList = 4; + + // ── Guards ──────────────────────────────────────────────────────────────── + + private static final ThreadLocal sAddingDownload = + ThreadLocal.withInitial(() -> Boolean.FALSE); + + private static final Set processedCreators = + Collections.newSetFromMap(new WeakHashMap<>()); + + // ── Entry point ────────────────────────────────────────────────────────── + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + loadMediaOptionEnum(classLoader); + findCreatorClassAndAddButtonMethod(bridge, classLoader); + installAddButtonHook(); + installClickHandlerHook(bridge, classLoader); + } + + // ── Step 1: MediaOption$Option.DOWNLOAD ────────────────────────────────── + + private static void loadMediaOptionEnum(ClassLoader cl) { + try { + mediaOptionEnumClass = cl.loadClass( + "com.instagram.feed.media.mediaoption.MediaOption$Option"); + Object[] values = (Object[]) mediaOptionEnumClass.getMethod("values").invoke(null); + for (Object v : values) { + if (downloadOptionValue == null && v.toString().equals("DOWNLOAD")) { + downloadOptionValue = v; + } + } + if (downloadOptionValue == null) + XposedBridge.log("(IE|Post) ❌ DOWNLOAD enum value not found"); + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ loadMediaOptionEnum: " + t); + } + } + + // ── Step 2: find MediaOptionsOverflowMenuCreator + its add-button method ─ + // + // Pass 1: findClass by string "MediaOptionsOverflowMenuCreator" (avoids crash + // that occurs when calling getMethodInstance() on a MethodData). + // Pass 2: findMethod(declaredClass, returnType=void) → filter for the static method + // that takes MediaOption$Option + ArrayList as params. + + private static void findCreatorClassAndAddButtonMethod(DexKitBridge bridge, + ClassLoader classLoader) { + // Cache hit: restore addButtonMethod and parameter indices without DexKit + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("PostDownload_addButton", classLoader); + if (cached != null) { + addButtonMethod = cached; + addButtonMethod.setAccessible(true); + menuCreatorClass = cached.getDeclaringClass(); + String idxStr = DexKitCache.loadString("PostDownload_addButtonIdx"); + if (idxStr != null) { + String[] parts = idxStr.split(","); + if (parts.length == 5) { + try { + idxEnum = Integer.parseInt(parts[0]); + idxOption = Integer.parseInt(parts[1]); + idxSelf = Integer.parseInt(parts[2]); + idxText = Integer.parseInt(parts[3]); + idxList = Integer.parseInt(parts[4]); + } catch (NumberFormatException ignored) {} + } + } + // Resolve enumNormalValue from the button-type enum parameter + if (idxEnum >= 0) { + try { + Class btnTypeEnum = cached.getParameterTypes()[idxEnum]; + Object[] vals = (Object[]) btnTypeEnum.getMethod("values").invoke(null); + Object first = null; + for (Object v : vals) { + if (first == null) first = v; + if (enumNormalValue == null && v.toString().equalsIgnoreCase("normal")) + enumNormalValue = v; + } + if (enumNormalValue == null) + for (Object v : vals) + if (v.toString().equalsIgnoreCase("action")) { enumNormalValue = v; break; } + if (enumNormalValue == null) enumNormalValue = first; + } catch (Throwable ignored) {} + } + return; + } + } + + try { + List pass1 = bridge.findClass(FindClass.create() + .matcher(ClassMatcher.create() + .usingStrings("MediaOptionsOverflowMenuCreator"))); + + if (pass1.isEmpty()) { + XposedBridge.log("(IE|Post) ❌ MediaOptionsOverflowMenuCreator class not found"); + return; + } + + String creatorClassName = pass1.get(0).getName(); + menuCreatorClass = classLoader.loadClass(creatorClassName); + + List pass2 = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .declaredClass(creatorClassName) + .returnType("void"))); + + for (MethodData md : pass2) { + try { + Method m = md.getMethodInstance(classLoader); + if (!Modifier.isStatic(m.getModifiers())) continue; + + Class[] p = m.getParameterTypes(); + if (p.length < 4) continue; + + int eIdx = -1, oIdx = -1, sIdx = -1, tIdx = -1, lIdx = -1; + for (int i = 0; i < p.length; i++) { + if (mediaOptionEnumClass != null && p[i] == mediaOptionEnumClass) { + oIdx = i; + } else if (ArrayList.class.isAssignableFrom(p[i])) { + lIdx = i; + } else if (p[i] == menuCreatorClass) { + sIdx = i; + } else if (CharSequence.class.isAssignableFrom(p[i])) { + tIdx = i; + } else if (p[i].isEnum() && eIdx < 0 && oIdx < 0) { + eIdx = i; + } + } + + if (oIdx < 0 || lIdx < 0) continue; + + addButtonMethod = m; + addButtonMethod.setAccessible(true); + idxEnum = eIdx >= 0 ? eIdx : 0; + idxOption = oIdx; + idxSelf = sIdx >= 0 ? sIdx : 2; + idxText = tIdx >= 0 ? tIdx : 3; + idxList = lIdx; + break; + } catch (Throwable ignored) {} + } + + if (addButtonMethod == null) { + XposedBridge.log("(IE|Post) ❌ addButtonMethod not found in " + creatorClassName); + return; + } + DexKitCache.saveMethod("PostDownload_addButton", addButtonMethod); + DexKitCache.saveString("PostDownload_addButtonIdx", + idxEnum + "," + idxOption + "," + idxSelf + "," + idxText + "," + idxList); + + // Resolve the "normal" button-type enum value + Class btnTypeEnumClass = addButtonMethod.getParameterTypes()[idxEnum]; + Object[] btnVals = (Object[]) btnTypeEnumClass.getMethod("values").invoke(null); + Object firstVal = null; + for (Object v : btnVals) { + if (firstVal == null) firstVal = v; + if (enumNormalValue == null && v.toString().equalsIgnoreCase("normal")) { + enumNormalValue = v; + } + } + if (enumNormalValue == null) { + for (Object v : btnVals) { + if (v.toString().equalsIgnoreCase("action")) { enumNormalValue = v; break; } + } + } + if (enumNormalValue == null) enumNormalValue = firstVal; + + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ findCreatorClassAndAddButtonMethod: " + t); + } + } + + // ── Hook A: intercept every addButton call, inject Download once per menu ─ + + private static void installAddButtonHook() { + if (addButtonMethod == null || downloadOptionValue == null || enumNormalValue == null) { + XposedBridge.log("(IE|Post) ❌ Cannot install addButton hook — prerequisites missing"); + return; + } + + XposedBridge.hookMethod(addButtonMethod, new XC_MethodHook() { + + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (Boolean.TRUE.equals(sAddingDownload.get())) return; + if (!FeatureFlags.enablePostDownload) return; + // Suppress Instagram's own native DOWNLOAD button — we add ours instead + if (param.args[idxOption] == downloadOptionValue) param.setResult(null); + } + + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enablePostDownload) return; + if (Boolean.TRUE.equals(sAddingDownload.get())) return; + if (param.args[idxOption] == downloadOptionValue) return; + + Object self = param.args[idxSelf]; + synchronized (processedCreators) { + if (processedCreators.contains(self)) return; + processedCreators.add(self); + } + + Object[] callArgs = new Object[addButtonMethod.getParameterCount()]; + System.arraycopy(param.args, 0, callArgs, 0, callArgs.length); + callArgs[idxEnum] = enumNormalValue; + callArgs[idxOption] = downloadOptionValue; + callArgs[idxText] = I18n.t(AndroidAppHelper.currentApplication(), R.string.ig_dl_title); + + sAddingDownload.set(true); + try { + addButtonMethod.invoke(null, callArgs); + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ addButton invoke failed: " + t); + } finally { + sAddingDownload.set(false); + } + } + }); + + FeatureStatusTracker.setHooked("PostDownload"); + XposedBridge.log("(IE|Post) ✅ Post download hook installed"); + } + + // ── Hook B: click handler ───────────────────────────────────────────────── + // + // DexKit finds void methods with sole param MediaOption$Option — a stable unobfuscated type. + // No string constants used, so obfuscation of surrounding code doesn't matter. + + private static void installClickHandlerHook(DexKitBridge bridge, ClassLoader classLoader) { + XC_MethodHook clickHook = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enablePostDownload) return; + onOptionClicked(param); + } + }; + + // Cache hit: restore all previously-found click handler methods + if (DexKitCache.isCacheValid()) { + List cached = DexKitCache.loadMethods("PostDownload_click", classLoader); + if (cached != null && !cached.isEmpty()) { + for (Method m : cached) XposedBridge.hookMethod(m, clickHook); + return; + } + } + + try { + String optionClassName = "com.instagram.feed.media.mediaoption.MediaOption$Option"; + + List results = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .returnType("void") + .paramTypes(optionClassName))); + + List hooked = new ArrayList<>(); + for (MethodData md : results) { + try { + Method m = md.getMethodInstance(classLoader); + if (Modifier.isStatic(m.getModifiers())) continue; + m.setAccessible(true); + XposedBridge.hookMethod(m, clickHook); + hooked.add(m); + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ Failed to hook click candidate: " + t); + } + } + + if (hooked.isEmpty()) { + XposedBridge.log("(IE|Post) ❌ No click handler methods could be hooked"); + } else { + DexKitCache.saveMethods("PostDownload_click", hooked); + } + + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ installClickHandlerHook: " + t); + } + } + + // ── Click dispatch ──────────────────────────────────────────────────────── + + private static void onOptionClicked(XC_MethodHook.MethodHookParam param) { + try { + if (Boolean.TRUE.equals(sAddingDownload.get())) return; + + // Find MediaOption$Option argument + Object clicked = null; + for (Object a : param.args) { + if (a != null && mediaOptionEnumClass != null && mediaOptionEnumClass.isInstance(a)) { + clicked = a; break; + } + } + if (clicked == null) { + for (Object a : param.args) { + if (a != null && a.getClass().isEnum() && a.toString().contains("DOWNLOAD")) { + clicked = a; break; + } + } + } + + if (clicked == null || !clicked.toString().equals("DOWNLOAD")) return; + + param.setResult(null); // consume the event + + Context ctx = findContext(param.thisObject); + if (ctx == null) { + XposedBridge.log("(IE|Post) ❌ Context not found in click handler"); + return; + } + + Object media = findMedia(param.thisObject); + if (media == null) { + XposedBridge.log("(IE|Post) ❌ Media not found in click handler"); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_no_media_for_post), Toast.LENGTH_SHORT).show(); + return; + } + + triggerDownload(ctx, media, param.thisObject); + } catch (Throwable t) { + XposedBridge.log("(IE|Post) ❌ onOptionClicked: " + t); + } + } + + // ── Download dispatch ───────────────────────────────────────────────────── + + private static void triggerDownload(Context ctx, Object media, Object clickHandler) { + String username = FeedVideoDownloadHook.extractUsernameFromMediaObject(media); + if (username == null) username = "post"; + + String mediaId = "0"; + try { + Object id = media.getClass().getMethod("getId").invoke(media); + if (id instanceof String s && !s.isEmpty()) mediaId = s; + } catch (Throwable ignored) {} + + List urls = FeedVideoDownloadHook.extractAllUrlsFromMedia(ctx, media); + + // Carousel index: try the known field name first; fall back to scanning all int fields + // for one whose value fits [0, urlCount) — resilient to field renames across builds. + int carouselIdx = findCarouselIndex(clickHandler, urls.size()); + + final String finalUser = username; + final String finalId = mediaId; + FeedVideoDownloadHook.mainHandler.post(() -> + FeedVideoDownloadHook.showPostDownloadDialog(ctx, urls, finalUser, finalId, carouselIdx)); + } + + /** + * Finds the current carousel slide index from the click handler object. + * Tries a known field name first (fast path), then scans all int fields for the + * first value in [0, urlCount) — only the index field will be in that range. + */ + static int findCarouselIndex(Object obj, int urlCount) { + if (obj == null || urlCount <= 1) return 0; + + // Fast path: try the currently known field name + try { + Field f = obj.getClass().getDeclaredField("A00"); + f.setAccessible(true); + int v = f.getInt(obj); + if (v >= 0 && v < urlCount) return v; + } catch (Throwable ignored) {} + + // Fallback: scan all int fields for a value that fits as a carousel index + for (Field f : obj.getClass().getDeclaredFields()) { + if (f.getType() != int.class) continue; + f.setAccessible(true); + try { + int v = f.getInt(obj); + if (v > 0 && v < urlCount) return v; // v > 0 skips 0-value flags/uninitialised + } catch (Throwable ignored) {} + } + + return 0; + } + + // ── Reflection helpers ──────────────────────────────────────────────────── + + private static Context findContext(Object obj) { + if (obj == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (!Context.class.isAssignableFrom(f.getType())) continue; + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v instanceof Context c) return c; + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } + + private static Object findMedia(Object obj) { + if (obj == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v != null && v.getClass().getName().equals("com.instagram.feed.media.Media")) + return v; + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/ProfilePicDownloadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/ProfilePicDownloadHook.java new file mode 100644 index 00000000..d5314c7c --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/ProfilePicDownloadHook.java @@ -0,0 +1,232 @@ +package ps.reso.instaeclipse.mods.media; + +import android.annotation.SuppressLint; +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.widget.Toast; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.net.HttpURLConnection; +import java.net.URL; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; + +/** + * Profile Picture Downloader + * + * Strategy: + * Hook View.onAttachedToWindow() globally, filter for "expanded_profile_pic" by resource name + * (cached as an int ID after first resolution). When found, attach a long-press listener that + * reads the ImageUrl field (getUrl()) from IgImageView and downloads via FeedVideoDownloadHook helpers. + * + * Gated by FeatureFlags.enableProfileDownload. + */ +public class ProfilePicDownloadHook { + + private static final Handler mainHandler = new Handler(Looper.getMainLooper()); + private static final String HOOKED_TAG = "ie_profile_dl"; + + /** Cached resource ID for "expanded_profile_pic"; 0 = not yet resolved. */ + private static volatile int expandedPicViewId = 0; + + // ── Install ─────────────────────────────────────────────────────────────── + + public static void install() { + // Mark status before hook setup so the toast shows correctly + if (FeatureFlags.enableProfileDownload) { + FeatureStatusTracker.setEnabled("ProfileDownload"); + FeatureStatusTracker.setHooked("ProfileDownload"); + } + + // Hook View.onAttachedToWindow — fires once per view attachment, works for any + // window type (Activity, Dialog, BottomSheet) without relying on layout listeners. + XposedHelpers.findAndHookMethod(View.class, "onAttachedToWindow", new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableProfileDownload) return; + View v = (View) param.thisObject; + int vid = v.getId(); + if (vid == View.NO_ID) return; + + // Fast path: cached int comparison (only resolves resource name once) + if (expandedPicViewId != 0) { + if (vid != expandedPicViewId) return; + } else { + try { + String name = v.getResources().getResourceEntryName(vid); + if (!"expanded_profile_pic".equals(name)) return; + expandedPicViewId = vid; + } catch (Throwable ignored) { return; } + } + + injectLongPress(v); + } + }); + } + + // ── UI injection ────────────────────────────────────────────────────────── + + private static void injectLongPress(View view) { + try { + view.setTag(HOOKED_TAG); + view.setOnLongClickListener(v -> { + // Resolve activity lazily at tap time — context is valid at this point + Context ctx = v.getContext(); + Activity activity = activityFromContext(ctx); + + String url = extractUrl(v); + if (url == null) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_profile_pic_url_not_found), Toast.LENGTH_SHORT).show(); + XposedBridge.log("(IE|ProfileDL) ❌ URL extraction failed"); + return true; + } + String username = activity != null ? extractUsername(activity) : null; + String filename = FeedVideoDownloadHook.buildFilename(username, "profile", null, false); + + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_downloading_profile_pic), Toast.LENGTH_SHORT).show(); + new Thread(() -> { + try { + boolean delegated = FeedVideoDownloadHook.downloadAndSave(ctx, url, filename, false, username); + if (!delegated) { + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_profile_pic_saved), Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + XposedBridge.log("(IE|ProfileDL) ❌ download: " + e.getMessage()); + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_download_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }).start(); + return true; + }); + + } catch (Throwable t) { + XposedBridge.log("(IE|ProfileDL) ❌ injectLongPress: " + t.getMessage()); + } + } + + // ── URL extraction ──────────────────────────────────────────────────────── + + /** + * Extracts the image URL from the profile pic view (CircularImageView extends IgImageView). + * Scans known ImageUrl-typed fields by name; tries multiple candidates in order. + */ + private static String extractUrl(View view) { + for (String fieldName : new String[]{"A0E", "A0D", "A0c"}) { + try { + String url = getUrlFromImageUrlField(view, fieldName); + if (url != null) return url; + } catch (Throwable ignored) {} + } + + // Fallback: tag-based URI + try { + Object tag = view.getTag(); + if (tag instanceof Uri) return tag.toString(); + if (tag instanceof String s && s.startsWith("http")) return s; + } catch (Throwable ignored) {} + + XposedBridge.log("(IE|ProfileDL) ❌ all URL strategies failed for " + view.getClass().getName()); + return null; + } + + /** + * Walks the class hierarchy to find a field by name, reads it as an ImageUrl, + * then calls getUrl() on it (ImageUrl is a non-obfuscated interface). + */ + private static String getUrlFromImageUrlField(View view, String fieldName) throws Throwable { + Class cls = view.getClass(); + while (cls != null && cls != Object.class) { + try { + Field f = cls.getDeclaredField(fieldName); + f.setAccessible(true); + Object imageUrl = f.get(view); + if (imageUrl == null) return null; + java.lang.reflect.Method getUrl = imageUrl.getClass().getMethod("getUrl"); + Object result = getUrl.invoke(imageUrl); + if (result instanceof String s && s.startsWith("http")) return s; + return null; + } catch (NoSuchFieldException e) { + cls = cls.getSuperclass(); + } + } + return null; + } + + // ── Username extraction ─────────────────────────────────────────────────── + + @SuppressLint("DiscouragedApi") + private static String extractUsername(Activity activity) { + try { + android.app.ActionBar ab = activity.getActionBar(); + if (ab != null && ab.getTitle() != null) { + String t = ab.getTitle().toString().trim(); + if (looksLikeUsername(t)) return t; + } + } catch (Throwable ignored) {} + + try { + int titleId = activity.getResources() + .getIdentifier("action_bar_title", "id", activity.getPackageName()); + if (titleId != 0) { + android.widget.TextView tv = activity.findViewById(titleId); + if (tv != null) { + String t = tv.getText().toString().trim(); + if (looksLikeUsername(t)) return t; + } + } + } catch (Throwable ignored) {} + + try { + CharSequence t = activity.getTitle(); + if (t != null && looksLikeUsername(t.toString().trim())) return t.toString().trim(); + } catch (Throwable ignored) {} + + return null; + } + + private static boolean looksLikeUsername(String s) { + return s != null && s.length() >= 1 && s.length() <= 30 + && s.matches("[a-zA-Z0-9._]+") + && !s.matches("\\d+"); + } + + // ── Context → Activity ──────────────────────────────────────────────────── + + private static Activity activityFromContext(Context ctx) { + while (ctx instanceof ContextWrapper) { + if (ctx instanceof Activity) return (Activity) ctx; + ctx = ((ContextWrapper) ctx).getBaseContext(); + } + return null; + } + + // ── Download ────────────────────────────────────────────────────────────── + + private static void downloadToStream(String url, OutputStream out) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestProperty("User-Agent", + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"); + conn.connect(); + try (InputStream in = conn.getInputStream()) { + byte[] buf = new byte[32768]; + int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } finally { + conn.disconnect(); + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/ReelDownloadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/ReelDownloadHook.java new file mode 100644 index 00000000..135b6ef7 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/ReelDownloadHook.java @@ -0,0 +1,392 @@ +package ps.reso.instaeclipse.mods.media; + +import android.app.Activity; +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Toast; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; + +import java.io.OutputStream; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; + +public class ReelDownloadHook { + + private static Class controllerClass; + private static Method hookMethod; + + private static Method buttonAdderMethod; + private static Field activityField; + + // Cached field path to the carousel position holder on the controller. + // The position holder is identified structurally: a non-framework object field + // whose class has exactly ONE int field (survives obfuscation renames). + private static Field cachedOuterField = null; + private static Field cachedInnerField = null; + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("ReelDownload", classLoader); + if (cached != null) { + controllerClass = cached.getDeclaringClass(); + hookMethod = cached; + cached.setAccessible(true); + FeatureStatusTracker.setHooked("ReelDownload"); + XposedBridge.hookMethod(cached, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableReelDownload) return; + onOptionsBuilt(param); + } + }); + XposedBridge.log("(IE|Reel) ✅ hooked: " + hookMethod.getDeclaringClass().getName() + "." + hookMethod.getName()); + return; + } + } + + try { + var methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("ClipsOrganicMediaItemViewMoreOptionsController"))); + + if (methods.isEmpty()) { + XposedBridge.log("(IE|Reel) ❌ ClipsOrganicMediaItemViewMoreOptionsController not found"); + return; + } + + controllerClass = methods.get(0).getMethodInstance(classLoader).getDeclaringClass(); + + // Find the options-builder method: void(com.instagram.feed.media.Media, ) + Method target = null; + for (Method m : controllerClass.getDeclaredMethods()) { + if (m.getReturnType() != void.class) continue; + Class[] params = m.getParameterTypes(); + if (params.length < 2) continue; + if (!params[0].getName().equals("com.instagram.feed.media.Media")) continue; + if (params[1].isPrimitive() || params[1] == String.class) continue; + target = m; + break; + } + + if (target == null) { + XposedBridge.log("(IE|Reel) ❌ hook method (Media, ButtonAdder)V not found"); + return; + } + + target.setAccessible(true); + hookMethod = target; + DexKitCache.saveMethod("ReelDownload", target); + FeatureStatusTracker.setHooked("ReelDownload"); + + XposedBridge.hookMethod(target, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableReelDownload) return; + onOptionsBuilt(param); + } + }); + XposedBridge.log("(IE|Reel) ✅ hooked: " + hookMethod.getDeclaringClass().getName() + "." + hookMethod.getName()); + + } catch (Throwable t) { + XposedBridge.log("(IE|Reel) ❌ install: " + t); + } + } + + /** + * Fallback index resolver: structurally locates the carousel position holder on the + * controller. The holder is the unique non-framework field whose class has exactly + * ONE int field — this property survives obfuscation renames across IG versions. + * Values outside [0, 200) are excluded to filter out config constants. + * Result is cached after first resolution. + */ + private static int findReelCarouselIndex(Object controller) { + if (controller == null) return 0; + + if (cachedOuterField != null && cachedInnerField != null) { + try { + Object holder = cachedOuterField.get(controller); + if (holder != null) return cachedInnerField.getInt(holder); + } catch (Throwable ignored) {} + cachedOuterField = null; + cachedInnerField = null; + } + + int bestIdx = Integer.MAX_VALUE; + Field bestOuter = null; + Field bestInner = null; + + Class c = controller.getClass(); + while (c != null && c != Object.class) { + for (Field outerF : c.getDeclaredFields()) { + if (outerF.getType().isPrimitive()) continue; + String pkg = outerF.getType().getName(); + if (pkg.startsWith("android.") || pkg.startsWith("java.") + || pkg.startsWith("androidx.") || pkg.startsWith("kotlin.")) continue; + outerF.setAccessible(true); + Object nested; + try { nested = outerF.get(controller); } catch (Throwable ignored) { continue; } + if (nested == null) continue; + + Field singleIntField = null; + int intCount = 0; + Class nc = nested.getClass(); + while (nc != null && nc != Object.class) { + String npkg = nc.getName(); + if (npkg.startsWith("android.") || npkg.startsWith("java.") + || npkg.startsWith("androidx.") || npkg.startsWith("kotlin.")) break; + for (Field nf : nc.getDeclaredFields()) { + if (nf.getType() != int.class) continue; + intCount++; + singleIntField = nf; + if (intCount > 1) break; + } + if (intCount > 1) break; + nc = nc.getSuperclass(); + } + + if (intCount == 1 && singleIntField != null) { + singleIntField.setAccessible(true); + try { + int idx = singleIntField.getInt(nested); + if (idx >= 0 && idx < 200 && idx < bestIdx) { + bestIdx = idx; + bestOuter = outerF; + bestInner = singleIntField; + } + } catch (Throwable ignored) {} + } + } + c = c.getSuperclass(); + } + + if (bestOuter != null) { + cachedOuterField = bestOuter; + cachedInnerField = bestInner; + return bestIdx; + } + return 0; + } + + /** + * Primary index resolver: walks the activity's live view hierarchy to find a + * ViewPager / ViewPager2 / ReboundViewPager whose adapter item count equals + * {@code carouselSize} and returns its current data index. + * Always accurate since it reads live view state, not the data-layer field. + * + * @return current position [0, carouselSize), or -1 if not found + */ + private static int findCarouselIndexFromView(Context ctx, int carouselSize) { + if (!(ctx instanceof Activity)) return -1; + try { + View root = ((Activity) ctx).getWindow().getDecorView(); + return scanViewForCarousel(root, carouselSize); + } catch (Throwable ignored) { + return -1; + } + } + + /** Returns adapter item count, trying RecyclerView-style then PagerAdapter-style. */ + private static int adapterCount(Object adapter) { + try { return (int) adapter.getClass().getMethod("getItemCount").invoke(adapter); } catch (Throwable ignored) {} + try { return (int) adapter.getClass().getMethod("getCount").invoke(adapter); } catch (Throwable ignored) {} + return -1; + } + + /** + * Recursive DFS over the view tree. ViewPager / ViewPager2 / ReboundViewPager are + * AndroidX / Instagram common-UI classes — stable names, no obfuscation. + */ + private static int scanViewForCarousel(View view, int carouselSize) { + String cn = view.getClass().getName(); + + // ViewPager / ViewPager2 / ReboundViewPager and any subclass + if (cn.contains("ViewPager")) { + try { + Object adapter = view.getClass().getMethod("getAdapter").invoke(view); + if (adapter != null && adapterCount(adapter) == carouselSize) { + // Standard pagers: getCurrentItem() + // ReboundViewPager (Instagram looping carousel): getCurrentDataIndex() + for (String getter : new String[]{ + "getCurrentItem", "getCurrentDataIndex", + "getCurrentWrappedDataIndex", "getCurrentRawDataIndex"}) { + try { + int cur = (int) view.getClass().getMethod(getter).invoke(view); + if (cur >= 0) return cur; + } catch (NoSuchMethodException ignored) {} + } + } + } catch (Throwable ignored) {} + } + + // Horizontal RecyclerView (carousel, not the vertical feed list) + if (cn.contains("RecyclerView")) { + try { + Object adapter = view.getClass().getMethod("getAdapter").invoke(view); + if (adapter != null && adapterCount(adapter) == carouselSize) { + Object lm = view.getClass().getMethod("getLayoutManager").invoke(view); + if (lm != null) { + try { + int orientation = (int) lm.getClass().getMethod("getOrientation").invoke(lm); + if (orientation != 0 /* HORIZONTAL */) lm = null; + } catch (Throwable ignored) {} + if (lm != null) { + try { + int pos = (int) lm.getClass() + .getMethod("findFirstCompletelyVisibleItemPosition").invoke(lm); + if (pos >= 0) return pos; + } catch (Throwable ignored) {} + try { + int pos = (int) lm.getClass() + .getMethod("findFirstVisibleItemPosition").invoke(lm); + if (pos >= 0) return pos; + } catch (Throwable ignored) {} + } + } + } + } catch (Throwable ignored) {} + } + + if (view instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) view; + for (int i = 0; i < vg.getChildCount(); i++) { + int result = scanViewForCarousel(vg.getChildAt(i), carouselSize); + if (result >= 0) return result; + } + } + + return -1; + } + + private static void onOptionsBuilt(XC_MethodHook.MethodHookParam param) { + try { + Object controller = param.thisObject; + Object media = param.args[0]; + Object buttonAdder = param.args[1]; + + if (activityField == null) { + for (Field f : controller.getClass().getDeclaredFields()) { + if (Activity.class.isAssignableFrom(f.getType())) { + f.setAccessible(true); + activityField = f; + break; + } + } + } + if (activityField == null) { + XposedBridge.log("(IE|Reel) ❌ no Activity field on controller"); + return; + } + + Activity activity = (Activity) activityField.get(controller); + if (activity == null) return; + + if (buttonAdderMethod == null) { + for (Method m : buttonAdder.getClass().getDeclaredMethods()) { + Class[] p = m.getParameterTypes(); + if (p.length != 4) continue; + if (!Context.class.isAssignableFrom(p[0])) continue; + if (!View.OnClickListener.class.isAssignableFrom(p[1])) continue; + if (p[2] != String.class) continue; + if (p[3] != int.class) continue; + m.setAccessible(true); + buttonAdderMethod = m; + break; + } + } + if (buttonAdderMethod == null) { + XposedBridge.log("(IE|Reel) ❌ buttonAdderMethod not found"); + return; + } + + int icon = resolveDownloadIcon(activity); + final Activity actCopy = activity; + final Object mediaCopy = media; + final Object controllerCopy = controller; + + buttonAdderMethod.invoke(buttonAdder, activity, + (View.OnClickListener) v -> startReelDownload(actCopy, mediaCopy, controllerCopy), + I18n.t(activity, R.string.ig_dl_title), icon); + + } catch (Throwable t) { + XposedBridge.log("(IE|Reel) ❌ onOptionsBuilt: " + t); + } + } + + private static void startReelDownload(Context ctx, Object media, Object controller) { + String username = FeedVideoDownloadHook.extractUsernameFromMediaObject(media); + if (username == null) username = "reel"; + + String mediaId = "0"; + try { + Object id = media.getClass().getMethod("getId").invoke(media); + if (id instanceof String s && !s.isEmpty()) mediaId = s; + } catch (Throwable ignored) {} + + String videoUrl = FeedVideoDownloadHook.bestVideoUrlFromMedia(media); + + if (videoUrl != null) { + final String fn = FeedVideoDownloadHook.buildFilename(username, "reel", mediaId, true); + final String finalUrl = videoUrl; + final String finalUser = username; + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_downloading_reel), Toast.LENGTH_SHORT).show(); + FeedVideoDownloadHook.executor.submit(() -> { + try { + boolean delegated = FeedVideoDownloadHook.downloadAndSave(ctx, finalUrl, fn, true, finalUser); + if (!delegated) { + FeedVideoDownloadHook.mainHandler.post(() -> + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_reel_saved), Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + FeedVideoDownloadHook.mainHandler.post(() -> + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_reel_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }); + return; + } + + // No direct video — may be a photo carousel reel + List allUrls = FeedVideoDownloadHook.extractAllUrlsFromMedia(ctx, media); + if (allUrls.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_reel_url_not_found), Toast.LENGTH_SHORT).show(); + return; + } + + // Primary: live view state (always correct). Fallback: data-layer field (stale at slide 0). + int viewIndex = findCarouselIndexFromView(ctx, allUrls.size()); + int currentIndex = viewIndex >= 0 ? viewIndex : findReelCarouselIndex(controller); + + final String finalUsername = username; + final String finalMediaId = mediaId; + final int finalIndex = currentIndex; + FeedVideoDownloadHook.mainHandler.post(() -> + FeedVideoDownloadHook.showPostDownloadDialog(ctx, allUrls, finalUsername, finalMediaId, finalIndex)); + } + + /** Reads the icon drawable ID from MediaOption$Option.DOWNLOAD enum value. */ + private static int resolveDownloadIcon(Context ctx) { + try { + Class optionClass = ctx.getClassLoader() + .loadClass("com.instagram.feed.media.mediaoption.MediaOption$Option"); + for (Object val : (Object[]) optionClass.getMethod("values").invoke(null)) { + if (val.toString().contains("DOWNLOAD")) { + Field f = val.getClass().getField("iconDrawable"); + return (int) f.get(val); + } + } + } catch (Throwable ignored) {} + return 0; + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/media/StoryDownloadHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/media/StoryDownloadHook.java new file mode 100644 index 00000000..215944e6 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/media/StoryDownloadHook.java @@ -0,0 +1,700 @@ +package ps.reso.instaeclipse.mods.media; + +import android.app.AndroidAppHelper; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.widget.Toast; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; +import ps.reso.instaeclipse.utils.users.UserUtils; + +public class StoryDownloadHook { + + + + // VideoVersionIntf resolved once at install time — same interface used by feed downloader + private static Class videoVersionIntfClass; + private static Method videoVersionGetUrl; + + private static final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // Username + media ID resolved at download trigger time + private volatile String currentStoryUsername = null; + private volatile String currentStoryMediaId = null; + + // ── Entry point ────────────────────────────────────────────────────────── + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + try { + videoVersionIntfClass = classLoader.loadClass("com.instagram.model.mediasize.VideoVersionIntf"); + videoVersionGetUrl = videoVersionIntfClass.getMethod("getUrl"); + } catch (Throwable ignored) {} + + installButtonInjectorHook(bridge, classLoader); + installClickHandlerHook(bridge, classLoader); + } + + // ── Hook 1: inject "Download" into the story options button list ────────── + // + // Found via "[INTERNAL] Pause Playback" string + CharSequence[] return type, 1 param. + // afterHookedMethod: appends our "Download" entry to the returned CharSequence[] array. + + private void installButtonInjectorHook(DexKitBridge bridge, ClassLoader classLoader) { + Method method = DexKitCache.isCacheValid() + ? DexKitCache.loadMethod("StoryDownload_button", classLoader) : null; + + if (method == null) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("[INTERNAL] Pause Playback") + .paramCount(1))); + + if (methods.isEmpty()) { + XposedBridge.log("(IE|Story) ❌ Button builder method not found"); + return; + } + + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + if (m.getReturnType().isArray() && + CharSequence.class.isAssignableFrom(m.getReturnType().getComponentType())) { + method = m; + break; + } + } catch (Throwable ignored) {} + } + } catch (Throwable t) { + XposedBridge.log("(IE|Story) ❌ Button builder DexKit: " + t); + return; + } + } + + if (method == null) { + XposedBridge.log("(IE|Story) ❌ No CharSequence[] return type candidate found"); + return; + } + DexKitCache.saveMethod("StoryDownload_button", method); + + try { + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableStoryDownload) return; + CharSequence[] original = (CharSequence[]) param.getResult(); + if (original == null) return; + + // Guard: don't inject twice + String dlLabel = I18n.t(AndroidAppHelper.currentApplication(), R.string.ig_dl_title); + for (CharSequence cs : original) { + if (dlLabel.contentEquals(cs)) return; + } + + CharSequence[] extended = new CharSequence[original.length + 1]; + System.arraycopy(original, 0, extended, 0, original.length); + extended[original.length] = dlLabel; + param.setResult(extended); + } + }); + + } catch (Throwable t) { + XposedBridge.log("(IE|Story) ❌ Button builder hook: " + t); + } + } + + // ── Hook 2: handle click on our "Download" option ──────────────────────── + // + // Found via "explore_viewer" + "friendships/mute_friend_reel/%s/" strings. + // beforeHookedMethod: reads the CharSequence param (the tapped label); if it + // equals "Download", triggers the download. Context and ReelItem are resolved + // from fields on 'this' or same-class params. + + private void installClickHandlerHook(DexKitBridge bridge, ClassLoader classLoader) { + Method method = DexKitCache.isCacheValid() + ? DexKitCache.loadMethod("StoryDownload_click", classLoader) : null; + + if (method == null) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .returnType("void") + .usingStrings("explore_viewer", + "friendships/mute_friend_reel/%s/", + "[INTERNAL] Pause Playback"))); + + if (methods.isEmpty()) { + XposedBridge.log("(IE|Story) ❌ Click handler not found"); + return; + } + method = methods.get(0).getMethodInstance(classLoader); + DexKitCache.saveMethod("StoryDownload_click", method); + } catch (Throwable t) { + XposedBridge.log("(IE|Story) ❌ Click handler DexKit: " + t); + return; + } + } + + try { + XposedBridge.hookMethod(method, new XC_MethodHook() { + + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableStoryDownload) return; + + // 1. Find which button was tapped + CharSequence tapped = null; + for (Object arg : param.args) { + if (arg instanceof CharSequence cs) { tapped = cs; break; } + } + String dlLabel = I18n.t(AndroidAppHelper.currentApplication(), R.string.ig_dl_title); + if (tapped == null || !dlLabel.contentEquals(tapped)) return; + + // 2. Consume the event — Instagram won't process an option it didn't add + param.setResult(null); + + // 3. Locate the object that holds ReelItem — it is either 'this' or a + // parameter of the same declaring class (piko's smali shows the latter). + Object holder = findReelItemHolder(param); + XposedBridge.log("(IE|Story) holder=" + (holder != null ? holder.getClass().getName() : "null")); + + // 4. Extract the Context + Context ctx = findContext(holder != null ? holder : param.thisObject); + if (ctx == null) { + XposedBridge.log("(IE|Story) ❌ Context not found"); + return; + } + + // 5. Extract story URL via ReelItem → media object field graph + String url = extractStoryUrl(holder != null ? holder : param.thisObject); + XposedBridge.log("(IE|Story) url=" + url); + + if (url == null || url.isEmpty()) { + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_story_url_not_found), Toast.LENGTH_SHORT).show(); + return; + } + + Object effectiveHolder = holder != null ? holder : param.thisObject; + currentStoryUsername = extractUsernameFromReelItemHolder(effectiveHolder); + currentStoryMediaId = extractMediaIdFromReelItemHolder(effectiveHolder); + startDownload(ctx, url, isVideoUrl(url)); + } + }); + + FeatureStatusTracker.setHooked("StoryDownload"); + + } catch (Throwable t) { + XposedBridge.log("(IE|Story) ❌ Click handler hook: " + t); + } + } + + // ── URL extraction ──────────────────────────────────────────────────────── + + /** + * Finds the object (either 'this' or a same-class parameter) that holds the + * ReelItem field. The click handler sometimes receives a reference to the outer + * class as a parameter rather than p0/this. + */ + private static Object findReelItemHolder(XC_MethodHook.MethodHookParam param) { + if (hasReelItemField(param.thisObject)) return param.thisObject; + // Check method parameters — the outer class is sometimes passed as an arg + for (Object arg : param.args) { + if (arg != null && hasReelItemField(arg)) return arg; + } + return null; + } + + private static boolean hasReelItemField(Object obj) { + if (obj == null) return false; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (f.getType().getName().equals("com.instagram.model.reels.ReelItem")) return true; + } + cls = cls.getSuperclass(); + } + return false; + } + + /** + * Extracts the story media URL from the holder object. + * 1. Reads the ReelItem field from the holder. + * 2. Searches for VideoVersionIntf → video URL (videos). + * 3. Searches for image Candidate objects (CDN URL + width int + height int) and + * picks the one with the largest pixel area (photos). + * 4. Falls back to raw CDN string scan with area-based ranking. + */ + private static String extractStoryUrl(Object holder) { + if (holder == null) return null; + try { + Object reelItem = readFieldByTypeName(holder, "com.instagram.model.reels.ReelItem"); + XposedBridge.log("(IE|Story) reelItem=" + + (reelItem != null ? reelItem.getClass().getName() : "null")); + + Object target = reelItem != null ? reelItem : holder; + + // Try video URL via VideoVersionIntf scan + if (videoVersionIntfClass != null && videoVersionGetUrl != null) { + String videoUrl = findVideoUrl(target, + Collections.newSetFromMap(new IdentityHashMap<>()), 0); + if (videoUrl != null) return videoUrl; + } + + // For photo stories: walk the graph looking for image Candidate objects. + // A Candidate has a CDN URL string field + at least two int fields with + // plausible pixel dimensions. Field names are obfuscated so we match by type + // and value range. Pick the candidate with the largest width×height area. + List candidates = new ArrayList<>(); + collectImageCandidates(target, candidates, + Collections.newSetFromMap(new IdentityHashMap<>()), 0); + XposedBridge.log("(IE|Story) imageCandidates=" + candidates.size()); + if (!candidates.isEmpty()) { + candidates.sort((a, b) -> Integer.compare(b.area, a.area)); + XposedBridge.log("(IE|Story) bestCandidate area=" + candidates.get(0).area + + " url=" + candidates.get(0).url.substring(0, Math.min(80, candidates.get(0).url.length()))); + return candidates.get(0).url; + } + + // Last resort: raw CDN string scan + List cdnUrls = new ArrayList<>(); + scanCdnUrls(target, cdnUrls, 0, Collections.newSetFromMap(new IdentityHashMap<>())); + if (!cdnUrls.isEmpty()) return pickBestUrl(cdnUrls); + + } catch (Throwable t) { + XposedBridge.log("(IE|Story) extractStoryUrl error: " + t); + } + return null; + } + + /** Reads the first field whose declared type name equals {@code typeName}. */ + private static Object readFieldByTypeName(Object obj, String typeName) { + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (f.getType().getName().equals(typeName)) { + f.setAccessible(true); + try { return f.get(obj); } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } + + /** Depth-limited field-graph walk looking for a VideoVersionIntf and calling getUrl(). */ + private static String findVideoUrl(Object obj, Set visited, int depth) { + if (obj == null || depth > 5 || !visited.add(obj)) return null; + if (videoVersionIntfClass == null || videoVersionGetUrl == null) return null; + + if (videoVersionIntfClass.isInstance(obj)) { + try { + String url = (String) videoVersionGetUrl.invoke(obj); + if (url != null && isCdnUrl(url)) return url; + } catch (Throwable ignored) {} + } + + String cn = obj.getClass().getName(); + if (!cn.startsWith("X.") && !cn.startsWith("com.instagram.") && !cn.startsWith("com.facebook.")) + return null; + + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + if (val instanceof List list) { + for (Object elem : list) { + if (videoVersionIntfClass.isInstance(elem)) { + try { + String url = (String) videoVersionGetUrl.invoke(elem); + if (url != null && isCdnUrl(url)) return url; + } catch (Throwable ignored) {} + } + } + } else { + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || vcn.startsWith("com.instagram.") + || vcn.startsWith("com.facebook.")) { + String found = findVideoUrl(val, visited, depth + 1); + if (found != null) return found; + } + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + return null; + } + + /** Depth-limited field-graph scan for Instagram CDN URL strings. */ + private static void scanCdnUrls(Object obj, List out, int depth, Set visited) { + if (obj == null || depth > 5 || out.size() >= 20) return; + if (!visited.add(obj)) return; + String cn = obj.getClass().getName(); + if (cn.startsWith("android.") || cn.startsWith("java.lang.") || cn.startsWith("kotlin.")) return; + + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + if (val instanceof String s) { + if (isCdnUrl(s) && !out.contains(s)) out.add(s); + } else if (val instanceof List list) { + for (Object item : list) scanCdnUrls(item, out, depth + 1, visited); + } else { + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || vcn.startsWith("com.instagram.") + || vcn.startsWith("com.facebook.")) { + scanCdnUrls(val, out, depth + 1, visited); + } + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + + // ── Image candidate scanner ─────────────────────────────────────────────── + + private static final class CandidateInfo { + final String url; + final int area; + CandidateInfo(String url, int area) { this.url = url; this.area = area; } + } + + /** + * Walks the object graph looking for Instagram image Candidate objects. + * A Candidate is identified by having: + * • At least one String field/method that is a CDN image URL (not video) + * • At least two int/long fields/methods whose values are plausible pixel dimensions (50–20 000 px) + * + * Field names are ignored — they are obfuscated in Instagram builds. + * No-arg methods are also probed to handle Pando/LiveTree JNI-backed nodes where + * data is not exposed as Java fields (fixes lower-quality photos on some story types). + * The two largest plausible-dimension ints are multiplied to estimate the area. + */ + private static void collectImageCandidates(Object obj, List out, + Set visited, int depth) { + if (obj == null || depth > 7 || out.size() >= 40) return; + if (!visited.add(obj)) return; + + String cn = obj.getClass().getName(); + if (cn.startsWith("android.") || cn.startsWith("java.lang.") || cn.startsWith("kotlin.")) return; + + // Scan this object's own fields looking for (url + dims) pattern + String candidateUrl = null; + List dims = new ArrayList<>(); + + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + f.setAccessible(true); + try { + if (f.getType() == String.class) { + String v = (String) f.get(obj); + if (v != null && isCdnUrl(v) && !isVideoUrl(v)) candidateUrl = v; + } else if (f.getType() == int.class) { + int v = f.getInt(obj); + if (v >= 50 && v <= 20_000) dims.add(v); + } else if (f.getType() == long.class) { + long v = f.getLong(obj); + if (v >= 50 && v <= 20_000) dims.add((int) v); + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + + // Method probe for Pando/LiveTree JNI-backed nodes — data not exposed as Java fields + if (cn.startsWith("X.") || cn.startsWith("com.instagram.") || cn.startsWith("com.facebook.")) { + cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Method m : cls.getDeclaredMethods()) { + if (m.getParameterCount() != 0) continue; + try { + m.setAccessible(true); + Class ret = m.getReturnType(); + if (ret == String.class) { + Object r = m.invoke(obj); + if (r instanceof String s && isCdnUrl(s) && !isVideoUrl(s) + && candidateUrl == null) candidateUrl = s; + } else if (ret == int.class) { + Object r = m.invoke(obj); + if (r instanceof Integer v && v >= 50 && v <= 20_000) dims.add(v); + } else if (ret == long.class) { + Object r = m.invoke(obj); + if (r instanceof Long v && v >= 50 && v <= 20_000) dims.add((int)(long) v); + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + + if (candidateUrl != null && dims.size() >= 2) { + dims.sort(Collections.reverseOrder()); + out.add(new CandidateInfo(candidateUrl, dims.get(0) * dims.get(1))); + return; // leaf candidate — don't recurse further into it + } + + // Not a candidate — recurse into Instagram/Facebook/X. objects and lists + cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + if (val instanceof List list) { + for (Object item : list) + collectImageCandidates(item, out, visited, depth + 1); + } else if (!(val instanceof String)) { + String vcn = val.getClass().getName(); + if (vcn.startsWith("X.") || vcn.startsWith("com.instagram.") + || vcn.startsWith("com.facebook.")) { + collectImageCandidates(val, out, visited, depth + 1); + } + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static Context findContext(Object obj) { + if (obj == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (Context.class.isAssignableFrom(f.getType())) { + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v instanceof Context ctx) return ctx; + } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } + + private static boolean isCdnUrl(String url) { + if (url == null || url.isEmpty()) return false; + if (!url.startsWith("http://") && !url.startsWith("https://")) return false; + if (!url.contains("cdninstagram.com") && !url.contains("fbcdn.net")) return false; + if (url.contains("/t51.") && url.contains("-19/")) return false; // profile pics + return true; + } + + private static boolean isVideoUrl(String url) { + return url.contains("t50.") || url.contains("/o1/"); + } + + /** + * Prefer video URLs; among images pick the one with the largest pixel area. + * Instagram embeds resolution as NNNxNNN in CDN paths (e.g. 1080x1920), so + * parsing it directly is the most reliable way to select the full-size copy. + */ + private static String pickBestUrl(List urls) { + for (String u : urls) if (isVideoUrl(u)) return u; + String best = null; + int bestArea = 0; + for (String u : urls) { + int area = parseUrlArea(u); + if (area > bestArea) { bestArea = area; best = u; } + } + return best != null ? best : urls.get(0); + } + + /** Extracts the largest NNNxNNN area found inside a CDN URL. */ + private static int parseUrlArea(String url) { + int maxArea = 0; + int i = 0; + while (i < url.length()) { + // Find a digit run + if (!Character.isDigit(url.charAt(i))) { i++; continue; } + int numStart = i; + while (i < url.length() && Character.isDigit(url.charAt(i))) i++; + // Must be followed by 'x' + if (i >= url.length() || url.charAt(i) != 'x') continue; + i++; // skip 'x' + if (i >= url.length() || !Character.isDigit(url.charAt(i))) continue; + int numMid = i; + while (i < url.length() && Character.isDigit(url.charAt(i))) i++; + try { + int w = Integer.parseInt(url.substring(numStart, numMid - 1)); + int h = Integer.parseInt(url.substring(numMid, i)); + int area = w * h; + if (area > maxArea) maxArea = area; + } catch (NumberFormatException ignored) {} + } + return maxArea; + } + + // ── Username extraction ─────────────────────────────────────────────────── + + /** + * Tries to extract the story author's username from the holder or ReelItem object. + * ReelItem is non-obfuscated so getUser() and getUsername() are stable method names. + */ + private static String extractUsernameFromReelItemHolder(Object holder) { + if (holder == null) { + XposedBridge.log("(IE|Story|Username) holder is null"); + return null; + } + XposedBridge.log("(IE|Story|Username) searching in holder=" + holder.getClass().getName()); + try { + // Step 1: find the ReelItem field on the holder + Object reelItem = null; + Class cls = holder.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + f.setAccessible(true); + try { + Object val = f.get(holder); + if (val != null && val.getClass().getName() + .equals("com.instagram.model.reels.ReelItem")) { + reelItem = val; + XposedBridge.log("(IE|Story|Username) found ReelItem in field=" + + f.getName() + " on " + cls.getName()); + break; + } + } catch (Throwable ignored) {} + } + if (reelItem != null) break; + cls = cls.getSuperclass(); + } + if (reelItem == null && holder.getClass().getName() + .equals("com.instagram.model.reels.ReelItem")) { + reelItem = holder; + XposedBridge.log("(IE|Story|Username) holder is itself a ReelItem"); + } + if (reelItem == null) { + XposedBridge.log("(IE|Story|Username) ❌ ReelItem not found in holder"); + return null; + } + + // Step 2: probe all no-arg non-primitive methods on ReelItem. + // Priority: find a method returning com.instagram.user.model.User. + // Fallback: if a method returns com.instagram.feed.media.Media → delegate to feed extractor. + for (Method m : reelItem.getClass().getDeclaredMethods()) { + if (m.getParameterCount() != 0) continue; + Class ret = m.getReturnType(); + if (ret.isPrimitive() || ret == String.class || ret == void.class) continue; + try { + m.setAccessible(true); + Object candidate = m.invoke(reelItem); + if (candidate == null) continue; + + String candidateClass = candidate.getClass().getName(); + + // Direct User object — use DexKit-resolved getter (stable int constant -265713450) + if (candidateClass.equals("com.instagram.user.model.User")) { + String username = UserUtils.callUsernameGetter(candidate); + if (username != null) { + XposedBridge.log("(IE|Story|Username) reelItem." + m.getName() + "() [User] → " + username); + return username; + } + continue; + } + + // Media object — delegate to feed extractor (has LiveTreeMediaDict path) + if (candidateClass.equals("com.instagram.feed.media.Media")) { + String username = FeedVideoDownloadHook.extractUsernameFromMediaObject(candidate); + if (username != null) { + XposedBridge.log("(IE|Story|Username) reelItem." + m.getName() + + "() [Media] → " + username); + return username; + } + continue; // don't probe String methods on Media + } + } catch (Throwable ignored) {} + } + + XposedBridge.log("(IE|Story|Username) ❌ username not found on ReelItem methods"); + } catch (Throwable t) { + XposedBridge.log("(IE|Story|Username) ❌ Exception: " + t); + } + return null; + } + + private static boolean looksLikeUsername(String s) { + return s != null && s.length() >= 2 && s.length() <= 30 + && s.matches("[a-zA-Z0-9._]+") + && !s.matches("\\d+"); // exclude pure numeric IDs + } + + /** Extracts the short media ID from the ReelItem held by the holder (first segment of getId()). */ + private static String extractMediaIdFromReelItemHolder(Object holder) { + if (holder == null) return null; + try { + Object reelItem = readFieldByTypeName(holder, "com.instagram.model.reels.ReelItem"); + if (reelItem == null && holder.getClass().getName().equals("com.instagram.model.reels.ReelItem")) { + reelItem = holder; + } + if (reelItem == null) return null; + Object id = reelItem.getClass().getMethod("getId").invoke(reelItem); + if (id instanceof String s && !s.isEmpty()) return s.split("_")[0]; + } catch (Throwable ignored) {} + return null; + } + + // ── Download dispatch ───────────────────────────────────────────────────── + + private void startDownload(Context ctx, String url, boolean isVideo) { + String fn = FeedVideoDownloadHook.buildFilename(currentStoryUsername, "story", currentStoryMediaId, isVideo); + XposedBridge.log("(IE|Story|DL) username=" + currentStoryUsername + " mediaId=" + currentStoryMediaId + + " file=" + fn); + Toast.makeText(ctx, isVideo ? I18n.t(ctx, R.string.ig_toast_downloading_story_video) : I18n.t(ctx, R.string.ig_toast_downloading_story_photo), Toast.LENGTH_SHORT).show(); + mainHandler.post(() -> new Thread(() -> { + try { + boolean delegated = FeedVideoDownloadHook.downloadAndSave(ctx, url, fn, isVideo, currentStoryUsername); + if (!delegated) { + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_story_saved), Toast.LENGTH_SHORT).show()); + } + } catch (Throwable e) { + mainHandler.post(() -> Toast.makeText(ctx, + I18n.t(ctx, R.string.ig_toast_download_failed, e.getMessage()), Toast.LENGTH_SHORT).show()); + } + }).start()); + } + + private static void downloadToStream(String url, java.io.OutputStream out) throws Exception { + java.net.HttpURLConnection conn = (java.net.HttpURLConnection) new java.net.URL(url).openConnection(); + conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36"); + conn.connect(); + try (java.io.InputStream in = conn.getInputStream()) { + byte[] buf = new byte[32768]; int n; + while ((n = in.read(buf)) != -1) out.write(buf, 0, n); + } finally { conn.disconnect(); } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/CommentCopyHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/CommentCopyHook.java new file mode 100644 index 00000000..0f44f8c8 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/misc/CommentCopyHook.java @@ -0,0 +1,735 @@ +package ps.reso.instaeclipse.mods.misc; + +import android.app.Activity; +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; + +/** + * CommentCopyHook + * + * Detects long-press on a comment row (identified by the view tag + * "row_comment_section_container_*") and shows a copy dialog. + * + * Text extraction uses a two-pass approach: + * 1. contentDescription suffix parsing (language-aware) + * 2. Recursive TextView / reflection getText() fallback + * The reflection fallback handles Instagram custom text-view subclasses + * that do not inherit android.widget.TextView, which caused the + * "Container found but text empty" failure on newer builds. + */ +public class CommentCopyHook { + + private static final Handler MAIN = new Handler(Looper.getMainLooper()); + private static volatile Activity currentActivity = null; + + private static Runnable pendingRunnable = null; + private static float downRawX = 0f; + private static float downRawY = 0f; + private static boolean longPressFired = false; + private static volatile View pendingRoot = null; + private static volatile Context pendingCtx = null; + + private static final long LONG_PRESS_MS = 500L; + private static final float SLOP_DP = 30f; + + private static final String COMMENT_ROW_TAG = "row_comment_section_container_"; + + private static final String[] COMMENT_SUFFIXES = { + " yorumunu yaptı", + " yorum yaptı", + " commented", + " hat kommentiert", + " a commenté", + " comentó", + " comentou", + " прокомментировал", + " прокомментировала", + " ha commentato", + " comentou", + " コメントしました", + " 댓글을 달았습니다", + " تعليق", + }; + + // ───────────────────────────────────────────────────────────────────────── + + public void install(ClassLoader classLoader) { + try { + XposedHelpers.findAndHookMethod(Activity.class, "onResume", new XC_MethodHook() { + @Override protected void afterHookedMethod(MethodHookParam p) { + currentActivity = (Activity) p.thisObject; + } + }); + XposedHelpers.findAndHookMethod(Activity.class, "onDestroy", new XC_MethodHook() { + @Override protected void afterHookedMethod(MethodHookParam p) { + if (currentActivity == p.thisObject) currentActivity = null; + } + }); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ⚠️ Activity tracker – " + t); + } + + hookWindow(Activity.class); + hookWindow(Dialog.class); + + FeatureStatusTracker.setHooked("CopyComment"); + } + + // ── Window hook ─────────────────────────────────────────────────────────── + + private static void hookWindow(Class cls) { + try { + XposedHelpers.findAndHookMethod(cls, "dispatchTouchEvent", MotionEvent.class, + new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableCopyComment) return; + + MotionEvent ev = (MotionEvent) param.args[0]; + Object thisObj = param.thisObject; + int action = ev.getActionMasked(); + + if (action == MotionEvent.ACTION_DOWN) { + View root; + Context ctx; + try { + if (thisObj instanceof Activity) { + Activity a = (Activity) thisObj; + root = a.getWindow().getDecorView(); + ctx = a; + } else { + Dialog d = (Dialog) thisObj; + Window w = d.getWindow(); + if (w == null) return; + root = w.getDecorView(); + ctx = activityCtx(d.getContext()); + } + } catch (Throwable t) { return; } + + cancelTimer(); + longPressFired = false; + downRawX = ev.getRawX(); + downRawY = ev.getRawY(); + pendingRoot = root; + pendingCtx = ctx; + + final float fx = ev.getRawX(); + final float fy = ev.getRawY(); + final View fRoot = root; + final Context fCtx = ctx; + + pendingRunnable = () -> { + pendingRunnable = null; + longPressFired = true; + + View commentContainer = findCommentContainer(fRoot, (int)fx, (int)fy); + + String text = null; + if (commentContainer != null) { + text = extractFromContainer(commentContainer); + } + + // Fallback for IG versions where Litho renders text directly + // onto the ComponentHost canvas — no tagged container exists, + // but the ViewGroup itself carries a contentDescription. + if (text == null || text.trim().length() < 2) { + text = findCommentTextAtPoint(fRoot, (int)fx, (int)fy); + } + + if (text == null || text.trim().length() < 2) { + return; + } + + text = text.trim(); + XposedBridge.log("(InstaEclipse | CopyComment): ✅ Comment → [" + + text.substring(0, Math.min(60, text.length())) + "]"); + + showCopyPopup(fCtx, text); + }; + MAIN.postDelayed(pendingRunnable, LONG_PRESS_MS); + + } else if (action == MotionEvent.ACTION_MOVE) { + float density = pendingCtx != null + ? pendingCtx.getResources().getDisplayMetrics().density + : 3.0f; + float slop = SLOP_DP * density; + if (Math.abs(ev.getRawX() - downRawX) > slop + || Math.abs(ev.getRawY() - downRawY) > slop) { + cancelTimer(); + } + + } else if (action == MotionEvent.ACTION_UP + || action == MotionEvent.ACTION_CANCEL) { + if (!longPressFired) cancelTimer(); + longPressFired = false; + } + } + }); + + XposedBridge.log("(InstaEclipse | CopyComment): ✅ Hooked " + + cls.getSimpleName() + ".dispatchTouchEvent"); + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ Hook [" + + cls.getSimpleName() + "] – " + t.getMessage()); + } + } + + // ── Timer ───────────────────────────────────────────────────────────────── + + private static void cancelTimer() { + if (pendingRunnable != null) { + MAIN.removeCallbacks(pendingRunnable); + pendingRunnable = null; + } + pendingRoot = null; + pendingCtx = null; + } + + // ── Context helper ──────────────────────────────────────────────────────── + + private static Context activityCtx(Context c) { + if (c instanceof Activity) return c; + Activity a = currentActivity; + return (a != null) ? a : c; + } + + // ── Comment container detection ─────────────────────────────────────────── + + private static View findCommentContainer(View v, int sx, int sy) { + if (v == null || v.getVisibility() != View.VISIBLE) return null; + + int[] loc = new int[2]; + v.getLocationOnScreen(loc); + int l = loc[0], t = loc[1]; + int r = l + v.getWidth(), b = t + v.getHeight(); + if (sx < l || sx > r || sy < t || sy > b) return null; + + Object tag = v.getTag(); + if (tag instanceof String && ((String) tag).startsWith(COMMENT_ROW_TAG)) { + return v; + } + + if (v instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) v; + for (int i = vg.getChildCount() - 1; i >= 0; i--) { + View found = findCommentContainer(vg.getChildAt(i), sx, sy); + if (found != null) return found; + } + } + + return null; + } + + /** + * Fallback: walks every view that contains the touch point and checks if its + * contentDescription parses as a comment. Used on IG versions where Litho + * renders the text directly (no child TextView, no row tag). + */ + private static String findCommentTextAtPoint(View v, int sx, int sy) { + if (v == null || v.getVisibility() != View.VISIBLE) return null; + + int[] loc = new int[2]; + v.getLocationOnScreen(loc); + int l = loc[0], t = loc[1]; + int r = l + v.getWidth(), b = t + v.getHeight(); + if (sx < l || sx > r || sy < t || sy > b) return null; + + CharSequence cd = v.getContentDescription(); + if (cd != null && cd.length() > 0) { + String parsed = parseCommentCd(cd.toString()); + if (parsed != null) return parsed; + } + + if (v instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) v; + for (int i = vg.getChildCount() - 1; i >= 0; i--) { + String found = findCommentTextAtPoint(vg.getChildAt(i), sx, sy); + if (found != null) return found; + } + } + + return null; + } + + // ── Text extraction ─────────────────────────────────────────────────────── + + private static String extractFromContainer(View container) { + return collectBest(container, null); + } + + private static String collectBest(View v, String best) { + if (v == null) return best; + + // ── Litho ComponentHost: ask Litho's own text-content API ──────────── + // Litho renders text as drawables (TextDrawable) directly onto the + // ComponentHost canvas — no child TextViews are created. The only way + // to retrieve that text is via ComponentHost.getTextContent().getTextList(). + String componentClassName = v.getClass().getName(); + if (componentClassName.equals("com.facebook.litho.ComponentHost")) { + String lithoText = extractLithoComponentHostText(v); + if (lithoText != null && lithoText.length() > 3 + && (best == null || lithoText.length() > best.length())) { + best = lithoText; + } + } + + // ── contentDescription (older IG builds / Litho accessibility shim) ── + CharSequence cd = v.getContentDescription(); + if (cd != null && cd.length() > 0) { + String parsed = parseCommentCd(cd.toString()); + if (parsed != null && (best == null || parsed.length() > best.length())) { + best = parsed; + } + } + + // ── Standard TextView ───────────────────────────────────────────────── + if (v instanceof TextView && !(v instanceof EditText)) { + CharSequence cs = ((TextView) v).getText(); + if (cs != null) { + String s = cs.toString().trim(); + if (s.length() > 3 && (best == null || s.length() > best.length())) { + best = s; + } + } + } else if (!(v instanceof EditText)) { + // Reflection fallback for non-standard text view subclasses + try { + java.lang.reflect.Method getTextMethod = v.getClass().getMethod("getText"); + Object result = getTextMethod.invoke(v); + if (result instanceof CharSequence) { + String s = result.toString().trim(); + if (s.length() > 3 && (best == null || s.length() > best.length())) { + best = s; + } + } + } catch (Throwable ignored) {} + } + + if (v instanceof ViewGroup) { + ViewGroup vg = (ViewGroup) v; + for (int i = 0; i < vg.getChildCount(); i++) { + best = collectBest(vg.getChildAt(i), best); + } + } + + return best; + } + + /** + * Uses Litho's own accessibility API to retrieve all text rendered by a + * ComponentHost. Litho exposes ComponentHost.getTextContent() which returns + * a TextContent object whose getTextList() yields every CharSequence that + * Litho drew as a TextDrawable — invisible to the normal View hierarchy. + * + * Returns the longest non-trivial string found, or null. + */ + private static String extractLithoComponentHostText(View componentHost) { + try { + // ComponentHost.getTextContent() → TextContent + java.lang.reflect.Method getTextContent = + componentHost.getClass().getMethod("getTextContent"); + Object textContent = getTextContent.invoke(componentHost); + if (textContent == null) return null; + // Instagram's bundled Litho returns the List directly. + // Upstream Litho wraps it in a TextContent object with getTextList(). + java.util.List texts; + if (textContent instanceof java.util.List) { + texts = (java.util.List) textContent; + } else { + java.lang.reflect.Method getTextList = + textContent.getClass().getMethod("getTextList"); + Object list = getTextList.invoke(textContent); + if (list == null) return null; + texts = (java.util.List) list; + } + // Litho mounts text in component-tree order: username → timestamp → comment. + // Build an ordered candidate list, then return the last item (comment). + // Single-item lists are UI labels (e.g. "Reply") — ignore them. + java.util.List candidates = new java.util.ArrayList<>(); + for (Object item : texts) { + if (item == null) continue; + String s; + if (item instanceof CharSequence) { + s = charSequenceToString((CharSequence) item).trim(); + } else { + s = extractLongestCharSequenceField(item); + } + if (s == null || s.isEmpty() || s.startsWith("") || isTimestamp(s)) continue; + candidates.add(s); + } + // Need at least username + comment; a lone string is a button label, not a comment. + if (candidates.size() >= 2) return candidates.get(candidates.size() - 1); + return null; + + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ Litho text extraction – " + t); + return null; + } + } + + /** Returns true for short relative-time strings like "14h", "2d", "1w", "30m". */ + private static boolean isTimestamp(String s) { + return s.matches("\\d+[smhdw]"); + } + + /** + * For obfuscated wrapper objects that hold text in a CharSequence field rather + * than implementing CharSequence themselves. Reflects over all declared + * CharSequence fields and returns the longest value found. + */ + private static String extractLongestCharSequenceField(Object obj) { + String longest = null; + for (java.lang.reflect.Field f : obj.getClass().getDeclaredFields()) { + if (!CharSequence.class.isAssignableFrom(f.getType())) continue; + try { + f.setAccessible(true); + Object val = f.get(obj); + if (val == null) continue; + String s = val instanceof String + ? (String) val + : charSequenceToString((CharSequence) val); + s = s.trim(); + if (s.length() > 3 && !s.startsWith("") + && (longest == null || s.length() > longest.length())) { + longest = s; + } + } catch (Throwable ignored) {} + } + return longest; + } + + /** Reads a CharSequence char-by-char — safe for obfuscated implementations + * that don't override toString(). */ + private static String charSequenceToString(CharSequence cs) { + if (cs == null) return ""; + // String.valueOf() on a real String subclass works fine; for custom + // implementations we use StringBuilder via the CharSequence interface. + if (cs instanceof String) return (String) cs; + StringBuilder sb = new StringBuilder(cs.length()); + for (int i = 0; i < cs.length(); i++) sb.append(cs.charAt(i)); + return sb.toString(); + } + + private static String parseCommentCd(String cd) { + // Format used by newer Instagram (Litho-based): + // "username said comment_text" + // Username can't contain spaces, so the first " said " split is safe. + int saidIdx = cd.indexOf(" said "); + if (saidIdx > 0 && saidIdx < 60) { + String body = cd.substring(saidIdx + 6).trim(); + if (body.length() >= 2) return body; + } + + // Legacy format: "username, comment_text " + // e.g. "john, nice photo commented" / "john, super foto hat kommentiert" + String suffix = null; + for (String s : COMMENT_SUFFIXES) { + if (cd.endsWith(s)) { suffix = s; break; } + } + if (suffix != null) { + String body = cd.substring(0, cd.length() - suffix.length()).trim(); + int commaSpace = body.indexOf(", "); + if (commaSpace > 0 && commaSpace < 50 && !body.substring(0, commaSpace).contains("\n")) { + body = body.substring(commaSpace + 2).trim(); + } + if (body.length() >= 2) return body; + } + + return null; + } + + // ── Theme helpers ───────────────────────────────────────────────────────── + + private static boolean isDarkTheme(Context ctx) { + return (ctx.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + } + + /** Rounded rectangle drawable. */ + private static GradientDrawable roundRect(int color, float radiusDp, Context ctx) { + float r = radiusDp * ctx.getResources().getDisplayMetrics().density; + GradientDrawable d = new GradientDrawable(); + d.setColor(color); + d.setCornerRadius(r); + return d; + } + + /** Creates a styled pill action button. */ + private static Button makeButton(Context ctx, String label, + int bgColor, int textColor, float dp) { + Button btn = new Button(ctx); + btn.setText(label); + btn.setTextColor(textColor); + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + btn.setTypeface(null, Typeface.BOLD); + btn.setBackground(roundRect(bgColor, 14, ctx)); + btn.setAllCaps(false); + btn.setPadding((int)(20 * dp), (int)(14 * dp), (int)(20 * dp), (int)(14 * dp)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + lp.topMargin = (int)(10 * dp); + btn.setLayoutParams(lp); + return btn; + } + + // ── Main bottom-sheet popup ─────────────────────────────────────────────── + + private static void showCopyPopup(final Context ctx, final String text) { + MAIN.post(() -> { + try { + float dp = ctx.getResources().getDisplayMetrics().density; + boolean dark = isDarkTheme(ctx); + + // Colors + int sheetBg = dark ? Color.parseColor("#1C1C1E") : Color.parseColor("#F2F2F7"); + int cardBg = dark ? Color.parseColor("#2C2C2E") : Color.parseColor("#FFFFFF"); + int textPrim = dark ? Color.WHITE : Color.parseColor("#1C1C1E"); + int textSec = dark ? Color.parseColor("#AEAEB2") : Color.parseColor("#6C6C70"); + int accentBg = Color.parseColor("#0A84FF"); + int secondBg = dark ? Color.parseColor("#3A3A3C") : Color.parseColor("#E5E5EA"); + int secondText = dark ? Color.WHITE : Color.parseColor("#1C1C1E"); + int handleClr = dark ? Color.parseColor("#48484A") : Color.parseColor("#C7C7CC"); + + // Root sheet + LinearLayout sheet = new LinearLayout(ctx); + sheet.setOrientation(LinearLayout.VERTICAL); + sheet.setBackground(roundRect(sheetBg, 20, ctx)); + int hPad = (int)(20 * dp); + sheet.setPadding(hPad, (int)(12 * dp), hPad, (int)(28 * dp)); + + // Drag handle + View handle = new View(ctx); + LinearLayout.LayoutParams handleLp = new LinearLayout.LayoutParams( + (int)(40 * dp), (int)(4 * dp)); + handleLp.gravity = Gravity.CENTER_HORIZONTAL; + handleLp.bottomMargin = (int)(16 * dp); + handle.setLayoutParams(handleLp); + handle.setBackground(roundRect(handleClr, 2, ctx)); + sheet.addView(handle); + + // Title + TextView titleTv = new TextView(ctx); + titleTv.setText(I18n.t(ctx, R.string.ig_comment_copy_title)); + titleTv.setTextColor(textPrim); + titleTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + titleTv.setTypeface(null, Typeface.BOLD); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + titleLp.bottomMargin = (int)(14 * dp); + titleTv.setLayoutParams(titleLp); + sheet.addView(titleTv); + + // Comment preview card + TextView commentTv = new TextView(ctx); + commentTv.setText(text); + commentTv.setTextColor(textPrim); + commentTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + commentTv.setMaxLines(5); + commentTv.setEllipsize(TextUtils.TruncateAt.END); + int cardPad = (int)(14 * dp); + commentTv.setPadding(cardPad, cardPad, cardPad, cardPad); + commentTv.setBackground(roundRect(cardBg, 12, ctx)); + LinearLayout.LayoutParams cardLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + cardLp.bottomMargin = (int)(6 * dp); + commentTv.setLayoutParams(cardLp); + sheet.addView(commentTv); + + // Buttons + Dialog dialog = new Dialog(ctx); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + + Button btnCopy = makeButton(ctx, I18n.t(ctx, R.string.ig_comment_copy_full), accentBg, Color.WHITE, dp); + btnCopy.setOnClickListener(v -> { + dialog.dismiss(); + copyToClipboard(ctx, text); + }); + sheet.addView(btnCopy); + + Button btnSelect = makeButton(ctx, I18n.t(ctx, R.string.ig_comment_select_part), secondBg, secondText, dp); + btnSelect.setOnClickListener(v -> { + dialog.dismiss(); + showSelectDialog(ctx, text); + }); + sheet.addView(btnSelect); + + // Wire dialog + dialog.setContentView(sheet); + Window w = dialog.getWindow(); + if (w != null) { + w.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + w.setGravity(Gravity.BOTTOM); + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT); + // Small margin from screen edges + WindowManager.LayoutParams wlp = w.getAttributes(); + int margin = (int)(12 * dp); + wlp.x = margin; + wlp.y = margin; + w.setAttributes(wlp); + } + dialog.show(); + + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ Popup – " + t.getMessage()); + } + }); + } + + // ── Select dialog ───────────────────────────────────────────────────────── + + private static void showSelectDialog(final Context ctx, final String text) { + MAIN.post(() -> { + try { + float dp = ctx.getResources().getDisplayMetrics().density; + boolean dark = isDarkTheme(ctx); + + int sheetBg = dark ? Color.parseColor("#1C1C1E") : Color.parseColor("#F2F2F7"); + int cardBg = dark ? Color.parseColor("#2C2C2E") : Color.parseColor("#FFFFFF"); + int textPrim = dark ? Color.WHITE : Color.parseColor("#1C1C1E"); + int accentBg = Color.parseColor("#0A84FF"); + int secondBg = dark ? Color.parseColor("#3A3A3C") : Color.parseColor("#E5E5EA"); + int handleClr= dark ? Color.parseColor("#48484A") : Color.parseColor("#C7C7CC"); + + LinearLayout sheet = new LinearLayout(ctx); + sheet.setOrientation(LinearLayout.VERTICAL); + sheet.setBackground(roundRect(sheetBg, 20, ctx)); + int hPad = (int)(20 * dp); + sheet.setPadding(hPad, (int)(12 * dp), hPad, (int)(28 * dp)); + + // Drag handle + View handle = new View(ctx); + LinearLayout.LayoutParams handleLp = new LinearLayout.LayoutParams( + (int)(40 * dp), (int)(4 * dp)); + handleLp.gravity = Gravity.CENTER_HORIZONTAL; + handleLp.bottomMargin = (int)(16 * dp); + handle.setLayoutParams(handleLp); + handle.setBackground(roundRect(handleClr, 2, ctx)); + sheet.addView(handle); + + // Title + TextView titleTv = new TextView(ctx); + titleTv.setText(I18n.t(ctx, R.string.ig_comment_select_title)); + titleTv.setTextColor(textPrim); + titleTv.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + titleTv.setTypeface(null, Typeface.BOLD); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + titleLp.bottomMargin = (int)(14 * dp); + titleTv.setLayoutParams(titleLp); + sheet.addView(titleTv); + + // Selectable EditText in a rounded card + EditText et = new EditText(ctx); + et.setText(text); + et.setTextColor(textPrim); + et.setTextIsSelectable(true); + et.setFocusableInTouchMode(true); + et.setInputType(android.text.InputType.TYPE_NULL); + et.setKeyListener(null); + et.setHorizontallyScrolling(false); + et.setMaxLines(8); + et.setTextSize(TypedValue.COMPLEX_UNIT_SP, 14); + int cardPad = (int)(14 * dp); + et.setPadding(cardPad, cardPad, cardPad, cardPad); + et.setBackground(roundRect(cardBg, 12, ctx)); + et.setSelection(0, text.length()); + LinearLayout.LayoutParams etLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + etLp.bottomMargin = (int)(6 * dp); + et.setLayoutParams(etLp); + sheet.addView(et); + + Dialog dialog = new Dialog(ctx); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + + Button btnCopySel = makeButton(ctx, I18n.t(ctx, R.string.ig_comment_copy_selected), accentBg, Color.WHITE, dp); + btnCopySel.setOnClickListener(v -> { + int s = et.getSelectionStart(), e = et.getSelectionEnd(); + String sel = (s >= 0 && e > s) ? text.substring(s, e) : text; + dialog.dismiss(); + copyToClipboard(ctx, sel); + }); + sheet.addView(btnCopySel); + + Button btnCopyAll = makeButton(ctx, I18n.t(ctx, R.string.ig_mention_copy_all), secondBg, + dark ? Color.WHITE : Color.parseColor("#1C1C1E"), dp); + btnCopyAll.setOnClickListener(v -> { + dialog.dismiss(); + copyToClipboard(ctx, text); + }); + sheet.addView(btnCopyAll); + + dialog.setContentView(sheet); + Window w = dialog.getWindow(); + if (w != null) { + w.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + w.setGravity(Gravity.BOTTOM); + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT); + WindowManager.LayoutParams wlp = w.getAttributes(); + int margin = (int)(12 * dp); + wlp.x = margin; + wlp.y = margin; + w.setAttributes(wlp); + } + dialog.show(); + et.requestFocus(); + + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ SelectDialog – " + t.getMessage()); + } + }); + } + + // ── Clipboard ───────────────────────────────────────────────────────────── + + private static void copyToClipboard(final Context ctx, final String text) { + try { + ClipboardManager cm = (ClipboardManager) + ctx.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm != null) { + cm.setPrimaryClip(ClipData.newPlainText("comment", text)); + MAIN.post(() -> + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_comment_copied), Toast.LENGTH_SHORT).show()); + } + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse | CopyComment): ❌ Copy – " + t.getMessage()); + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryFlipping.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableStoryFlippingHook.java similarity index 72% rename from app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryFlipping.java rename to app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableStoryFlippingHook.java index 83e69e29..826cefff 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryFlipping.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableStoryFlippingHook.java @@ -11,11 +11,27 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; -public class StoryFlipping { +public class DisableStoryFlippingHook { + + private static final XC_MethodHook HOOK = new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + if (FeatureFlags.disableStoryFlipping) param.setResult(null); + } + }; public void handleStoryFlippingDisable(DexKitBridge bridge) { + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("StoryFlipping", Module.hostClassLoader); + if (cached != null) { + XposedBridge.hookMethod(cached, HOOK); + XposedBridge.log("(InstaEclipse | StoryFlipping): ✅ Hooked (dynamic check): " + cached.getDeclaringClass().getName() + "." + cached.getName()); + return; + } + } try { findAndHookMethod(bridge); } catch (Exception e) { @@ -45,16 +61,8 @@ private void findAndHookMethod(DexKitBridge bridge) { for (MethodData method : methods) { try { Method targetMethod = method.getMethodInstance(Module.hostClassLoader); - - XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - if (FeatureFlags.disableStoryFlipping) { - // If disableStoryFlipping is enabled, block story flipping - param.setResult(null); // Skip original method - } - } - }); + DexKitCache.saveMethod("StoryFlipping", targetMethod); + XposedBridge.hookMethod(targetMethod, HOOK); XposedBridge.log("(InstaEclipse | StoryFlipping): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/AutoPlayDisable.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableVideoAutoPlayHook.java similarity index 76% rename from app/src/main/java/ps/reso/instaeclipse/mods/misc/AutoPlayDisable.java rename to app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableVideoAutoPlayHook.java index 940e2aa4..29974e41 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/misc/AutoPlayDisable.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/misc/DisableVideoAutoPlayHook.java @@ -11,11 +11,19 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.feature.FeatureFlags; -public class AutoPlayDisable { +public class DisableVideoAutoPlayHook { public void handleAutoPlayDisable(DexKitBridge bridge) { + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod("AutoPlayDisable", Module.hostClassLoader); + if (cached != null) { + hookMethod(cached); + return; + } + } try { findAndHookDynamicMethod(bridge); } catch (Exception e) { @@ -57,23 +65,21 @@ private void findAndHookDynamicMethod(DexKitBridge bridge) { private void hookMethod(MethodData method) { try { Method targetMethod = method.getMethodInstance(Module.hostClassLoader); - - // Step 3: Hook the method dynamically - XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { - @Override - protected void beforeHookedMethod(MethodHookParam param) throws Throwable { - if (FeatureFlags.disableVideoAutoPlay) { - // If disableVideoAutoPlay is true, force return true - param.setResult(true); - } - } - }); - + DexKitCache.saveMethod("AutoPlayDisable", targetMethod); + hookMethod(targetMethod); XposedBridge.log("(InstaEclipse | AutoPlayDisable): ✅ Hooked (dynamic check): " + method.getClassName() + "." + method.getName()); - } catch (Exception e) { XposedBridge.log("(InstaEclipse | AutoPlayDisable): ❌ Error hooking method: " + e.getMessage()); } } + + private void hookMethod(Method targetMethod) { + XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) throws Throwable { + if (FeatureFlags.disableVideoAutoPlay) param.setResult(true); + } + }); + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowStatusHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowStatusHook.java new file mode 100644 index 00000000..5426fb67 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowStatusHook.java @@ -0,0 +1,192 @@ +package ps.reso.instaeclipse.mods.misc; + +import android.app.AndroidAppHelper; +import android.content.Context; +import android.os.Handler; +import android.os.Looper; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URI; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.i18n.I18n; +import ps.reso.instaeclipse.utils.toast.CustomToast; +import ps.reso.instaeclipse.utils.tracker.FollowIndicatorTracker; + +/** + * Handles the follow-status (follower toast) feature. + * + * IGNetworkInterceptor hooks TigonServiceLayer.startRequest and delegates here + * whenever a /friendships/show/ request is detected. This class owns all the + * callback registration, response parsing, and toast display logic. + */ +public class FollowStatusHook { + + /** identity-hash of a pending success-callback → userId being checked */ + private static final ConcurrentHashMap sPendingCallbacks = + new ConcurrentHashMap<>(); + + /** callback class names that have already been hooked (hook once per class) */ + private static final Set sHookedCallbackClasses = + Collections.synchronizedSet(new HashSet<>()); + + /** + * Entry point called from IGNetworkInterceptor for every TigonServiceLayer request. + * If the request is a /friendships/show/ call, captures the userId and registers + * response callbacks so the follow status can be parsed when the response arrives. + */ + public static void handleRequest(URI uri, Object[] args) { + String path = uri.getPath(); + if (!path.startsWith("/api/v1/friendships/show/")) return; + + String[] parts = path.split("/"); + if (parts.length < 6) return; + + final String capturedId = parts[5]; + FollowIndicatorTracker.currentlyViewedUserId = capturedId; + + if (args.length > 1) registerCallback(args[1], capturedId); + if (args.length > 2) registerCallback(args[2], capturedId); + } + + /** + * Lazily hooks all non-static, non-nullary methods of {@code cb}'s class and + * stores the identity-hash → userId mapping so we can parse the response when + * the callback fires. + */ + private static void registerCallback(Object cb, String userId) { + if (cb == null) return; + sPendingCallbacks.put(System.identityHashCode(cb), userId); + + String className = cb.getClass().getName(); + if (sHookedCallbackClasses.contains(className)) return; + sHookedCallbackClasses.add(className); + + for (Method m : cb.getClass().getDeclaredMethods()) { + if (java.lang.reflect.Modifier.isStatic(m.getModifiers())) continue; + if (m.getParameterCount() == 0) continue; + try { + XposedBridge.hookMethod(m, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam p) { + int hash = System.identityHashCode(p.thisObject); + String uid = sPendingCallbacks.get(hash); + if (uid == null) return; + + // Check args first + for (Object arg : p.args) { + Boolean followedBy = parseFollowedBy(arg); + if (followedBy != null) { + sPendingCallbacks.remove(hash); + showFollowToast(uid, followedBy); + return; + } + } + + // TigonServiceLayer often stores the response body as a field on + // the callback instance rather than passing it as an argument. + Boolean followedByFromObj = parseFollowedBy(p.thisObject); + if (followedByFromObj != null) { + sPendingCallbacks.remove(hash); + showFollowToast(uid, followedByFromObj); + } + } + }); + } catch (Throwable ignored) {} + } + } + + /** + * Attempts to find and return the "followed_by" boolean from {@code arg}, + * which may be a String, byte[], or object with a body-accessor method. + * Returns null if the argument doesn't contain friendship data. + */ + private static Boolean parseFollowedBy(Object arg) { + // Depth 3: Callback -> Response Wrapper -> Payload/Body + String body = toJsonString(arg, 3); + if (body == null || !body.contains("\"followed_by\"")) return null; + try { + return new org.json.JSONObject(body).optBoolean("followed_by", false); + } catch (Throwable ignored) { + return body.contains("\"followed_by\":true") || body.contains("\"followed_by\": true"); + } + } + + /** + * Converts {@code obj} to a JSON string containing "followed_by", or null. + * Checks the value itself, common accessor methods, and up to 2 levels of + * declared fields — covers both arg-based and thisObject-based responses. + */ + private static String toJsonString(Object obj) { + return toJsonString(obj, 2); + } + + private static String toJsonString(Object obj, int depth) { + if (obj == null || depth < 0) return null; + + // Direct ByteBuffer handling + if (obj instanceof java.nio.ByteBuffer) { + try { + java.nio.ByteBuffer dup = ((java.nio.ByteBuffer) obj).duplicate(); + if (dup.hasArray()) { + String s = new String(dup.array(), dup.arrayOffset(), dup.limit(), + java.nio.charset.StandardCharsets.UTF_8); + return s.contains("followed_by") ? s : null; + } + } catch (Throwable ignored) {} + } + + if (obj instanceof byte[]) { + try { + return new String((byte[]) obj, "UTF-8"); + } catch (Throwable ignored) {} + } + + // Recursive field scanning to find the body inside wrapper objects + if (depth > 0) { + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (java.lang.reflect.Modifier.isStatic(f.getModifiers())) continue; + f.setAccessible(true); + try { + Object val = f.get(obj); + if (val != null && val != obj && !(val instanceof Number)) { + String s = toJsonString(val, depth - 1); + if (s != null && s.contains("followed_by")) return s; + } + } catch (Throwable ignored) {} + } + cls = cls.getSuperclass(); + } + } + return null; + } + + /** Shows the follow-status toast on the main thread if userId is still pending. */ + private static void showFollowToast(String userId, boolean followedBy) { + new Handler(Looper.getMainLooper()).post(() -> { + try { + String currentTarget = FollowIndicatorTracker.currentlyViewedUserId; + if (currentTarget == null || !userId.equals(currentTarget)) return; + + Context ctx = AndroidAppHelper.currentApplication().getApplicationContext(); + String statusStr = followedBy + ? I18n.t(ctx, R.string.ig_toast_follows_you) + : I18n.t(ctx, R.string.ig_toast_not_follows_you); + + CustomToast.showCustomToast(ctx, "(" + userId + ") " + statusStr); + + // Clear tracker so we don't process duplicate callback triggers + FollowIndicatorTracker.currentlyViewedUserId = null; + } catch (Throwable ignored) {} + }); + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowerIndicator.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowerIndicator.java deleted file mode 100644 index 9dd8648e..00000000 --- a/app/src/main/java/ps/reso/instaeclipse/mods/misc/FollowerIndicator.java +++ /dev/null @@ -1,233 +0,0 @@ -package ps.reso.instaeclipse.mods.misc; - -import android.app.AndroidAppHelper; -import android.content.Context; - -import org.luckypray.dexkit.DexKitBridge; -import org.luckypray.dexkit.query.FindMethod; -import org.luckypray.dexkit.query.matchers.MethodMatcher; -import org.luckypray.dexkit.result.MethodData; - -import java.util.ArrayList; -import java.util.List; - -import de.robv.android.xposed.XC_MethodHook; -import de.robv.android.xposed.XposedBridge; -import de.robv.android.xposed.XposedHelpers; -import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -import ps.reso.instaeclipse.utils.toast.CustomToast; - -public class FollowerIndicator { - - public String type; - - public FollowMethodResult findFollowerStatusMethod(DexKitBridge bridge) { - try { - - // Step 1: Get the second Boolean method in FriendshipStatus - try { - - // Find all methods declared inside com.instagram.user.model.FriendshipStatus - List friendshipMethods = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().declaredClass("com.instagram.user.model.FriendshipStatus").returnType("java.lang.Boolean"))); - - if (friendshipMethods.size() >= 2) { - MethodData followedByMethod = friendshipMethods.get(1); // 2nd Boolean-returning method = followed_by (BeA) - type = "default"; - return new FollowMethodResult(followedByMethod.getName(), followedByMethod.getClassName()); - } - - } catch (Throwable ignore) { - } - - try { - // Step 2: Try method detection (obfuscated User class) - String obfUserClass = null; - List errMethods = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().usingStrings("ERROR_INSERT_EXPIRED_URL"))); - if (!errMethods.isEmpty()) { - obfUserClass = errMethods.get(0).getClassName(); - } - - if (obfUserClass != null) { - List methods = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().usingStrings("", "", "").paramTypes("com.instagram.common.session.UserSession", obfUserClass))); - - for (MethodData method : methods) { - - for (MethodData invoked : method.getInvokes()) { - String className = invoked.getClassName(); - String returnType = String.valueOf(invoked.getReturnType()); - - if (className.contains(obfUserClass) && returnType.contains("boolean")) { - type = "fallback - 1"; - return new FollowMethodResult(invoked.getName(), obfUserClass); - } - } - } - } - } catch (Throwable ignore) { - } - - try { - // Step 3: Fallback to old detection - List methodsOld = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().usingStrings("", "", "").paramCount(2))); - - for (MethodData method : methodsOld) { - List paramTypes = new ArrayList<>(); - for (Object param : method.getParamTypes()) { - paramTypes.add(String.valueOf(param)); - } - if (paramTypes.size() == 2 && paramTypes.get(0).contains("com.instagram.common.session.UserSession") && paramTypes.get(1).contains("com.instagram.user.model.User")) { - for (MethodData invoked : method.getInvokes()) { - if (invoked.getClassName().contains("com.instagram.user.model.User") && String.valueOf(invoked.getReturnType()).contains("boolean")) { - type = "fallback - 2"; - return new FollowMethodResult(invoked.getName(), "com.instagram.user.model.User"); - } - } - } - } - } catch (Throwable ignore) { - } - - } catch (Throwable e) { - XposedBridge.log("❌ Error in findFollowerStatusMethod: " + e.getMessage()); - } - return null; - } - - public String findUserIdClassIfNeeded(DexKitBridge bridge, String userClassName) { - - try { - - if (!"com.instagram.user.model.FriendshipStatus".equals(userClassName)) { - // Step 2 / Step 3 found a usable class — no need to search again - return userClassName; - } else { - String userClass = null; - - try { - // Find method referencing "username_missing_during_update" - List methods = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().usingStrings("username_missing_during_update"))); - - if (!methods.isEmpty()) { - MethodData m = methods.get(0); - userClass = m.getClassName(); - - // Verify toString exists - List toStringMethods = bridge.findMethod(FindMethod.create().matcher(MethodMatcher.create().declaredClass(userClass).name("toString").returnType("java.lang.String"))); - - if (!toStringMethods.isEmpty()) { - toStringMethods.get(0).getName(); - MethodData toStringMethodData = toStringMethods.get(0); // keep MethodData - - // Inspect what toString() calls internally - List invokedByToString = toStringMethodData.getInvokes(); - for (MethodData invoked : invokedByToString) { - - // return invoked class - return invoked.getClassName(); - - } - } - } - - } catch (Throwable e) { - XposedBridge.log("❌ Error finding user class via 'username_missing_during_update': " + e.getMessage()); - } - return userClass; - } - - } catch (Throwable ignore) { - } - - - return null; - } - - public void checkFollow(ClassLoader classLoader, String followerStatusMethod, String userClassName, String userIdClassName) { - try { - - final String[] userId = {null}; - - // If DexKit gave us the interface, switch to the implementation - if (userClassName.equals("com.instagram.user.model.FriendshipStatus")) { - //userClassName = userIdClassName; - userClassName = "com.instagram.user.model.FriendshipStatusImpl"; - try { - if (userIdClassName != null) { - final String methodName = "getId"; - - // Hook into that class’s toString() - XposedHelpers.findAndHookMethod(userIdClassName, classLoader, methodName, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - userId[0] = (String) param.getResult(); - - } - }); - } - } catch (Throwable t) { - XposedBridge.log("❌ Failed to hook toString() fallback: " + t.getMessage()); - } - } - - String finalUserClassName = userClassName; - XposedHelpers.findAndHookMethod(userClassName, classLoader, followerStatusMethod, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - Object user = param.thisObject; - - if (!finalUserClassName.equals("com.instagram.user.model.FriendshipStatusImpl")) { - try { - // Try the usual User.getId() - userId[0] = (String) XposedHelpers.callMethod(param.thisObject, "getId"); - } catch (Throwable ignored) { - - } - } - - String username = null; - try { - username = (String) XposedHelpers.callMethod(user, "getUsername"); - } catch (Throwable ignored) { - // skip username for now in obfuscated versions - } - - Boolean followsMe = (Boolean) param.getResult(); - String targetId = ps.reso.instaeclipse.utils.tracker.FollowIndicatorTracker.currentlyViewedUserId; - try { - if (userId[0].equals(targetId)) { - Context context = AndroidAppHelper.currentApplication().getApplicationContext(); - String message; - if (username != null && !username.isEmpty()) { - message = "@" + username + " (" + userId[0] + ") " + (followsMe ? "follows you ✅" : "doesn’t follow you ❌"); - } else { - message = " (" + userId[0] + ") " + (followsMe ? "follows you ✅" : "doesn’t follow you ❌"); - } - CustomToast.showCustomToast(context, message); - ps.reso.instaeclipse.utils.tracker.FollowIndicatorTracker.currentlyViewedUserId = null; - } - } catch (Throwable ignore) { - - } - - } - }); - - - XposedBridge.log("(InstaEclipse | FollowerStatus): ✅ Hooked (" + type + "): " + userClassName + "." + followerStatusMethod); - FeatureStatusTracker.setHooked("ShowFollowerToast"); - - } catch (Exception e) { - XposedBridge.log("❌ Error hooking follower status: " + e.getMessage()); - } - } - - public static class FollowMethodResult { - public final String methodName; - public final String userClassName; - - public FollowMethodResult(String methodName, String userClassName) { - this.methodName = methodName; - this.userClassName = userClassName; - } - } -} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryMentionHook.java b/app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryMentionHook.java new file mode 100644 index 00000000..e3c5d008 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/mods/misc/StoryMentionHook.java @@ -0,0 +1,521 @@ +package ps.reso.instaeclipse.mods.misc; + +import android.app.AndroidAppHelper; +import android.app.Dialog; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Color; +import android.graphics.Typeface; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.GradientDrawable; +import android.os.Handler; +import android.os.Looper; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.View; +import android.view.Window; +import android.view.WindowManager; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; +import android.widget.Toast; + +import org.luckypray.dexkit.DexKitBridge; +import org.luckypray.dexkit.query.FindMethod; +import org.luckypray.dexkit.query.matchers.MethodMatcher; +import org.luckypray.dexkit.result.MethodData; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; + +import de.robv.android.xposed.XC_MethodHook; +import de.robv.android.xposed.XposedBridge; +import ps.reso.instaeclipse.R; +import ps.reso.instaeclipse.utils.core.DexKitCache; +import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; +import ps.reso.instaeclipse.utils.i18n.I18n; +import ps.reso.instaeclipse.utils.users.UserUtils; + +public class StoryMentionHook { + + + + // Resolved once: MediaExtKt.A1s(Media) → List + // DexKit anchor: only static (Media)→List method on MediaExtKt that accesses a field + // declared on com.instagram.reels.interactive.Interactive (field A1I of type User). + private static volatile Method mentionGetterMethod = null; + private static final List mentionGetterCandidates = new ArrayList<>(); + + private static final Handler mainHandler = new Handler(Looper.getMainLooper()); + + // ── Entry point ────────────────────────────────────────────────────────── + + public void install(DexKitBridge bridge, ClassLoader classLoader) { + resolveMentionGetter(bridge, classLoader); + installButtonHook(bridge, classLoader); + installClickHook(bridge, classLoader); + FeatureStatusTracker.setHooked("StoryMentions"); + } + + // ── DexKit: resolve the mention getter ─────────────────────────────────── + // + // Targets the only static (Media)→List method on MediaExtKt that reads a field + // declared on com.instagram.reels.interactive.Interactive. Found via usingFields + // on Interactive — cleaner than depending on a specific call-site string. + + private static void resolveMentionGetter(DexKitBridge bridge, ClassLoader classLoader) { + // Cache hit + if (DexKitCache.isCacheValid()) { + List cached = DexKitCache.loadMethods("MentionGetter", classLoader); + if (cached != null && !cached.isEmpty()) { + mentionGetterCandidates.addAll(cached); + mentionGetterMethod = mentionGetterCandidates.get(0); + XposedBridge.log("(IE|Mention) ✅ " + cached.size() + " candidate(s) from cache"); + return; + } + } + + try { + List results = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .declaredClass("com.instagram.feed.media.MediaExtKt") + .returnType("java.util.List") + .paramCount(1) + .usingStrings("Required value was null.") + )); + + for (MethodData md : results) { + try { + Method m = md.getMethodInstance(classLoader); + m.setAccessible(true); + mentionGetterCandidates.add(m); + } catch (Throwable ignored) {} + } + + if (!mentionGetterCandidates.isEmpty()) { + mentionGetterMethod = mentionGetterCandidates.get(0); + DexKitCache.saveMethods("MentionGetter", mentionGetterCandidates); + XposedBridge.log("(IE|Mention) ✅ " + mentionGetterCandidates.size() + " candidate(s) loaded"); + } else { + XposedBridge.log("(IE|Mention) ❌ mentionGetter not found"); + } + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ resolveMentionGetter: " + t); + } + } + + // ── Hook 1: append "View Mentions" to the story options list ───────────── + // + // Same anchor as StoryDownloadHook — CharSequence[] builder with "[INTERNAL] Pause Playback". + // Xposed stacks hooks, so both run independently on the same method. + + private void installButtonHook(DexKitBridge bridge, ClassLoader classLoader) { + Method method = null; + + if (DexKitCache.isCacheValid()) { + method = DexKitCache.loadMethod("MentionButton", classLoader); + } + + if (method == null) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .usingStrings("[INTERNAL] Pause Playback") + .paramCount(1))); + + for (MethodData md : methods) { + try { + Method m = md.getMethodInstance(classLoader); + if (m.getReturnType().isArray() && + CharSequence.class.isAssignableFrom(m.getReturnType().getComponentType())) { + method = m; + break; + } + } catch (Throwable ignored) {} + } + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ button hook DexKit: " + t); + } + } + + if (method == null) { + XposedBridge.log("(IE|Mention) ❌ button builder not found"); + return; + } + DexKitCache.saveMethod("MentionButton", method); + + try { + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + if (!FeatureFlags.enableStoryMentions) return; + CharSequence[] original = (CharSequence[]) param.getResult(); + if (original == null) return; + String mentionLabel = I18n.t(AndroidAppHelper.currentApplication(), R.string.ig_btn_view_mentions); + for (CharSequence cs : original) { + if (mentionLabel.contentEquals(cs)) return; + } + CharSequence[] extended = new CharSequence[original.length + 1]; + System.arraycopy(original, 0, extended, 0, original.length); + extended[original.length] = mentionLabel; + param.setResult(extended); + } + }); + XposedBridge.log("(IE|Mention) ✅ button hook installed"); + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ button hook: " + t); + } + } + + // ── Hook 2: handle "View Mentions" tap ─────────────────────────────────── + // + // Same anchor as StoryDownloadHook click handler. We intercept only our label; + // all other taps pass through to Instagram and to the StoryDownloadHook. + + private void installClickHook(DexKitBridge bridge, ClassLoader classLoader) { + Method method = null; + + if (DexKitCache.isCacheValid()) { + method = DexKitCache.loadMethod("MentionClick", classLoader); + } + + if (method == null) { + try { + List methods = bridge.findMethod(FindMethod.create() + .matcher(MethodMatcher.create() + .returnType("void") + .usingStrings("explore_viewer", + "friendships/mute_friend_reel/%s/", + "[INTERNAL] Pause Playback"))); + if (methods.isEmpty()) { + XposedBridge.log("(IE|Mention) ❌ click handler not found"); + return; + } + method = methods.get(0).getMethodInstance(classLoader); + DexKitCache.saveMethod("MentionClick", method); + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ click hook DexKit: " + t); + return; + } + } + + try { + XposedBridge.hookMethod(method, new XC_MethodHook() { + @Override + protected void beforeHookedMethod(MethodHookParam param) { + try { + if (!FeatureFlags.enableStoryMentions) return; + + CharSequence tapped = null; + for (Object a : param.args) { + if (a instanceof CharSequence cs && tapped == null) tapped = cs; + } + String mentionLabel = I18n.t(AndroidAppHelper.currentApplication(), R.string.ig_btn_view_mentions); + if (tapped == null || !mentionLabel.contentEquals(tapped)) return; + + param.setResult(null); // consume event + + Set visited = Collections.newSetFromMap(new IdentityHashMap<>()); + Object media = null; + Context ctx = null; + + if (param.thisObject != null) { + media = findMediaInGraph(param.thisObject, 0, visited); + ctx = findContext(param.thisObject); + } + for (Object a : param.args) { + if (a == null) continue; + if (media == null) media = findMediaInGraph(a, 0, visited); + if (ctx == null) ctx = findContext(a); + } + + if (ctx == null) { XposedBridge.log("(IE|Mention) ❌ context not found"); return; } + if (media == null) { XposedBridge.log("(IE|Mention) ❌ Media not found"); return; } + + showMentionsDialog(ctx, resolveMentions(media)); + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ click handler: " + t); + } + } + }); + XposedBridge.log("(IE|Mention) ✅ click hook installed"); + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ click hook: " + t); + } + } + + // ── Mention extraction ──────────────────────────────────────────────────── + + // media is already resolved by the caller — passed in directly + private static List resolveMentions(Object media) { + List usernames = new ArrayList<>(); + try { + if (mentionGetterCandidates.isEmpty()) { + XposedBridge.log("(IE|Mention) ❌ no mentionGetter candidates"); + return usernames; + } + + // On first real call, probe all candidates and pin the one returning User objects. + // A1n returns List user IDs; A1o returns List with usernames. + if (mentionGetterMethod == null || mentionGetterMethod == mentionGetterCandidates.get(0)) { + for (Method candidate : mentionGetterCandidates) { + try { + Object probe = candidate.invoke(null, media); + if (!(probe instanceof List list) || list.isEmpty()) continue; + Object first = list.get(0); + if (first != null && !(first instanceof String)) { + // Found the User-returning method — pin it + mentionGetterMethod = candidate; + XposedBridge.log("(IE|Mention) ✅ pinned to " + candidate.getName() + " (returns User objects)"); + break; + } + } catch (Throwable ignored) {} + } + } + + Object result = mentionGetterMethod.invoke(null, media); + if (!(result instanceof List list)) return usernames; + + for (Object item : list) { + if (item == null) continue; + String username = (item instanceof String s) ? null : UserUtils.callUsernameGetter(item); + if (username != null && !username.isEmpty()) usernames.add(username); + } + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) resolveMentions exception: " + t); + } + return usernames; + } + + // Recursively walk fields (including Object-typed ones) to find a Media instance. + // Checks runtime class name, not declared field type, so it works through Object fields. + private static final int GRAPH_MAX_DEPTH = 6; + + private static Object findMediaInGraph(Object obj, int depth, Set visited) { + if (obj == null || depth > GRAPH_MAX_DEPTH) return null; + if (!visited.add(obj)) return null; + + String className = obj.getClass().getName(); + if (!className.startsWith("com.instagram.") && + !className.startsWith("com.facebook.") && + !className.startsWith("X.")) return null; + + if (className.equals("com.instagram.feed.media.Media")) return obj; + + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + Class ft = f.getType(); + if (ft.isPrimitive() || ft.isArray()) continue; + f.setAccessible(true); + Object val; + try { val = f.get(obj); } catch (Throwable ignored) { continue; } + if (val == null) continue; + + String vn = val.getClass().getName(); + if (vn.equals("com.instagram.feed.media.Media")) return val; + if (vn.startsWith("com.instagram.") || vn.startsWith("com.facebook.") || vn.startsWith("X.")) { + Object found = findMediaInGraph(val, depth + 1, visited); + if (found != null) return found; + } + } + cls = cls.getSuperclass(); + } + return null; + } + + // ── Bottom sheet dialog ─────────────────────────────────────────────────── + + private static void showMentionsDialog(Context ctx, List usernames) { + mainHandler.post(() -> { + try { + float dp = ctx.getResources().getDisplayMetrics().density; + boolean dk = (ctx.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + + int sheetBg = dk ? Color.parseColor("#1C1C1E") : Color.parseColor("#F2F2F7"); + int cardBg = dk ? Color.parseColor("#2C2C2E") : Color.parseColor("#FFFFFF"); + int textPrim = dk ? Color.WHITE : Color.parseColor("#1C1C1E"); + int textSec = dk ? Color.parseColor("#AEAEB2") : Color.parseColor("#6C6C70"); + int accentBg = Color.parseColor("#0A84FF"); + int handleClr = dk ? Color.parseColor("#48484A") : Color.parseColor("#C7C7CC"); + + LinearLayout sheet = new LinearLayout(ctx); + sheet.setOrientation(LinearLayout.VERTICAL); + sheet.setBackground(roundRect(sheetBg, 20, ctx, dp)); + int hPad = (int)(20 * dp); + sheet.setPadding(hPad, (int)(12 * dp), hPad, (int)(28 * dp)); + + // Drag handle + View handle = new View(ctx); + LinearLayout.LayoutParams handleLp = new LinearLayout.LayoutParams( + (int)(40 * dp), (int)(4 * dp)); + handleLp.gravity = Gravity.CENTER_HORIZONTAL; + handleLp.bottomMargin = (int)(16 * dp); + handle.setLayoutParams(handleLp); + handle.setBackground(roundRect(handleClr, 2, ctx, dp)); + sheet.addView(handle); + + // Title + TextView title = new TextView(ctx); + title.setText(I18n.t(ctx, R.string.ig_mention_dialog_title)); + title.setTextColor(textPrim); + title.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18); + title.setTypeface(null, Typeface.BOLD); + LinearLayout.LayoutParams titleLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + titleLp.bottomMargin = (int)(4 * dp); + title.setLayoutParams(titleLp); + sheet.addView(title); + + // Subtitle + TextView subtitle = new TextView(ctx); + subtitle.setText(usernames.isEmpty() + ? I18n.t(ctx, R.string.ig_mention_no_mentions) + : I18n.t(ctx, R.string.ig_mention_subtitle, usernames.size())); + subtitle.setTextColor(textSec); + subtitle.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13); + LinearLayout.LayoutParams subLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + subLp.bottomMargin = (int)(14 * dp); + subtitle.setLayoutParams(subLp); + sheet.addView(subtitle); + + Dialog dialog = new Dialog(ctx); + dialog.requestWindowFeature(Window.FEATURE_NO_TITLE); + + if (!usernames.isEmpty()) { + // Scrollable username list + ScrollView scroll = new ScrollView(ctx); + scroll.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + + LinearLayout list = new LinearLayout(ctx); + list.setOrientation(LinearLayout.VERTICAL); + + for (String username : usernames) { + TextView row = new TextView(ctx); + row.setText("@" + username); + row.setTextColor(textPrim); + row.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + row.setTypeface(null, Typeface.BOLD); + int rowPad = (int)(14 * dp); + row.setPadding(rowPad, rowPad, rowPad, rowPad); + row.setBackground(roundRect(cardBg, 12, ctx, dp)); + LinearLayout.LayoutParams rowLp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + rowLp.bottomMargin = (int)(8 * dp); + row.setLayoutParams(rowLp); + row.setOnClickListener(v -> { + ClipboardManager cm = (ClipboardManager) + ctx.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm != null) { + cm.setPrimaryClip(ClipData.newPlainText("username", username)); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_mention_copied, username), Toast.LENGTH_SHORT).show(); + } + }); + list.addView(row); + } + scroll.addView(list); + sheet.addView(scroll); + + // Copy all button (only shown when more than one mention) + if (usernames.size() > 1) { + Button btnAll = makePillButton(ctx, I18n.t(ctx, R.string.ig_mention_copy_all), accentBg, Color.WHITE, dp); + btnAll.setOnClickListener(v -> { + dialog.dismiss(); + StringBuilder sb = new StringBuilder(); + for (String u : usernames) sb.append("@").append(u).append("\n"); + ClipboardManager cm = (ClipboardManager) + ctx.getSystemService(Context.CLIPBOARD_SERVICE); + if (cm != null) { + cm.setPrimaryClip(ClipData.newPlainText("mentions", sb.toString().trim())); + Toast.makeText(ctx, I18n.t(ctx, R.string.ig_toast_all_mentions_copied), Toast.LENGTH_SHORT).show(); + } + }); + sheet.addView(btnAll); + } + } + + dialog.setContentView(sheet); + Window w = dialog.getWindow(); + if (w != null) { + w.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + w.setGravity(Gravity.BOTTOM); + w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.WRAP_CONTENT); + WindowManager.LayoutParams wlp = w.getAttributes(); + int margin = (int)(12 * dp); + wlp.x = margin; + wlp.y = margin; + w.setAttributes(wlp); + } + dialog.show(); + + } catch (Throwable t) { + XposedBridge.log("(IE|Mention) ❌ showMentionsDialog: " + t); + } + }); + } + + // ── UI helpers ──────────────────────────────────────────────────────────── + + private static GradientDrawable roundRect(int color, float radiusDp, Context ctx, float dp) { + GradientDrawable d = new GradientDrawable(); + d.setColor(color); + d.setCornerRadius(radiusDp * dp); + return d; + } + + private static Button makePillButton(Context ctx, String label, + int bgColor, int textColor, float dp) { + Button btn = new Button(ctx); + btn.setText(label); + btn.setTextColor(textColor); + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, 15); + btn.setTypeface(null, Typeface.BOLD); + btn.setBackground(roundRect(bgColor, 14, ctx, dp)); + btn.setAllCaps(false); + btn.setPadding((int)(20 * dp), (int)(14 * dp), (int)(20 * dp), (int)(14 * dp)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT); + lp.topMargin = (int)(10 * dp); + btn.setLayoutParams(lp); + return btn; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static Context findContext(Object obj) { + if (obj == null) return null; + Class cls = obj.getClass(); + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (Context.class.isAssignableFrom(f.getType())) { + f.setAccessible(true); + try { + Object v = f.get(obj); + if (v instanceof Context c) return c; + } catch (Throwable ignored) {} + } + } + cls = cls.getSuperclass(); + } + return null; + } + +} diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/network/Interceptor.java b/app/src/main/java/ps/reso/instaeclipse/mods/network/IGNetworkInterceptor.java similarity index 83% rename from app/src/main/java/ps/reso/instaeclipse/mods/network/Interceptor.java rename to app/src/main/java/ps/reso/instaeclipse/mods/network/IGNetworkInterceptor.java index aa7945e8..fe0e4faf 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/network/Interceptor.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/network/IGNetworkInterceptor.java @@ -8,11 +8,11 @@ import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; import de.robv.android.xposed.callbacks.XC_LoadPackage; +import ps.reso.instaeclipse.mods.misc.FollowStatusHook; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; -import ps.reso.instaeclipse.utils.tracker.FollowIndicatorTracker; -public class Interceptor { +public class IGNetworkInterceptor { public void handleInterceptor(XC_LoadPackage.LoadPackageParam lpparam) { try { @@ -38,7 +38,7 @@ public void handleInterceptor(XC_LoadPackage.LoadPackageParam lpparam) { } } - // Dynamically identify the URI field in c5aE + // Dynamically identify the URI field in the request object if (random_param_1 != null) { for (Field field : random_param_1.getDeclaredFields()) { if (field.getType().equals(URI.class)) { @@ -55,14 +55,19 @@ public void handleInterceptor(XC_LoadPackage.LoadPackageParam lpparam) { random_param_1, random_param_2, random_param_3, new XC_MethodHook() { @Override protected void beforeHookedMethod(MethodHookParam param) { - Object requestObj = param.args[0]; // Dynamic object + Object requestObj = param.args[0]; URI uri = (URI) XposedHelpers.getObjectField(requestObj, finalUriFieldName); if (uri != null && uri.getPath() != null) { - // Check all conditions passed in as predicates boolean shouldDrop = false; // Ghost Mode URIs + if (FeatureFlags.isGhostSeen) { + shouldDrop |= uri.getPath().contains("/threads/") && uri.getPath().contains("/opened"); + } + if (FeatureFlags.keepEphemeralMessages) { + shouldDrop |= uri.getPath().contains("/mark_ephemeral_item_ranges_viewed"); + } if (FeatureFlags.isGhostScreenshot) { shouldDrop |= uri.getPath().endsWith("/screenshot/") || uri.getPath().endsWith("/ephemeral_screenshot/"); } @@ -72,6 +77,7 @@ protected void beforeHookedMethod(MethodHookParam param) { } if (FeatureFlags.isGhostStory) { shouldDrop |= uri.getPath().contains("/api/v2/media/seen/"); + FeatureStatusTracker.setHooked("GhostStories"); } if (FeatureFlags.isGhostLive) { shouldDrop |= uri.getPath().contains("/heartbeat_and_get_viewer_count/"); @@ -134,45 +140,34 @@ protected void beforeHookedMethod(MethodHookParam param) { if (FeatureFlags.disableRepost) { shouldDrop |= uri.getPath().contains("/media/create_note/"); } + if (FeatureFlags.disableDiscoverPeople) { + shouldDrop |= uri.getPath().contains("/discover/ayml/"); + shouldDrop |= uri.getPath().contains("discover/chaining/"); + FeatureStatusTracker.setHooked("DisableDiscoverPeople"); + } if (shouldDrop) { - // XposedBridge.log("the URI was blocked: " + uri.getPath()); - // Modify the URI to divert the request to a harmless endpoint try { URI fakeUri = new URI("https", "127.0.0.1", "/404", null); XposedHelpers.setObjectField(requestObj, finalUriFieldName, fakeUri); - // XposedBridge.log("🚫 [InstaEclipse] Changed URI to: " + fakeUri); - } catch (Exception e) { - // XposedBridge.log("❌ [InstaEclipse] Failed to modify URI: " + e.getMessage()); - } + } catch (Exception ignored) {} } - /* - DEV Purposes - else { - XposedBridge.log("Logging: " + uri.getHost() + uri.getPath()); - } - */ + // Follow status if (FeatureFlags.showFollowerToast) { - if (uri.getPath() != null && uri.getPath().startsWith("/api/v1/friendships/show/")) { - String[] parts = uri.getPath().split("/"); - if (parts.length >= 5) { - // Extracted ID from /api/v1/friendships/show/{id} - FollowIndicatorTracker.currentlyViewedUserId = parts[5]; - } - } + FeatureStatusTracker.setHooked("FollowerToast"); + FollowStatusHook.handleRequest(uri, param.args); } - } } } ); } else { - XposedBridge.log("Could not resolve required classes or fields."); + XposedBridge.log("(InstaEclipse | Interceptor): Could not resolve required classes or fields."); } } catch (Exception e) { - XposedBridge.log("Error in interceptor: " + e.getMessage()); + XposedBridge.log("(InstaEclipse | Interceptor): ❌ " + e.getMessage()); } } } diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ui/UIHookManager.java b/app/src/main/java/ps/reso/instaeclipse/mods/ui/UIHookManager.java index ece7eab7..1254f453 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ui/UIHookManager.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ui/UIHookManager.java @@ -1,36 +1,44 @@ package ps.reso.instaeclipse.mods.ui; +import static org.luckypray.dexkit.query.FindMethod.create; import static ps.reso.instaeclipse.mods.ghost.ui.GhostEmojiManager.addGhostEmojiNextToInbox; import android.annotation.SuppressLint; import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Intent; +import android.content.IntentFilter; import android.os.Handler; import android.os.Looper; -import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; +import org.luckypray.dexkit.result.MethodData; + +import java.util.List; import java.util.Map; import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import de.robv.android.xposed.XposedHelpers; +import ps.reso.instaeclipse.R; import ps.reso.instaeclipse.Xposed.Module; import ps.reso.instaeclipse.mods.devops.config.ConfigManager; import ps.reso.instaeclipse.mods.ui.utils.BottomSheetHookUtil; import ps.reso.instaeclipse.mods.ui.utils.VibrationUtil; +import ps.reso.instaeclipse.utils.core.SettingsManager; import ps.reso.instaeclipse.utils.dialog.DialogUtils; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.feature.FeatureStatusTracker; import ps.reso.instaeclipse.utils.ghost.GhostModeUtils; +import ps.reso.instaeclipse.utils.i18n.I18n; import ps.reso.instaeclipse.utils.toast.CustomToast; public class UIHookManager { @SuppressLint("StaticFieldLeak") private static Activity currentActivity; - public static Activity getCurrentActivity() { return currentActivity; } @@ -85,49 +93,6 @@ public void onGlobalLayout() { addGhostEmojiNextToInbox(activity, GhostModeUtils.isGhostModeActive()); - // Mark messages (DM) as seen by holding on gallery button - hookLongPress(activity, "row_thread_composer_button_gallery", v -> { - VibrationUtil.vibrate(activity); - - if (!FeatureFlags.isGhostSeen) { - return true; - } - - FeatureFlags.isGhostSeen = false; - - activity.getWindow().getDecorView().post(() -> { - try { - // Look for the exact message list view by ID - @SuppressLint("DiscouragedApi") int messageListId = activity.getResources().getIdentifier("message_list", "id", activity.getPackageName()); - View view = activity.findViewById(messageListId); - - if (view instanceof ViewGroup messageList) { - - // Try scrolling via translation if standard scroll methods don't exist - messageList.scrollBy(0, -100); // scroll up - - new Handler(Looper.getMainLooper()).postDelayed(() -> { - messageList.scrollBy(0, 100); // scroll back down - - FeatureFlags.isGhostSeen = true; - Toast.makeText(activity, "✅ Message was marked as read", Toast.LENGTH_SHORT).show(); - - }, 300); - - - } else { - XposedBridge.log("⚠️ message_list not a ViewGroup or not found — fallback to reset flag"); - - new Handler(Looper.getMainLooper()).postDelayed(() -> FeatureFlags.isGhostSeen = true, 300); - } - } catch (Exception e) { - XposedBridge.log("❌ Exception in scroll logic: " + Log.getStackTraceString(e)); - } - }); - - return true; - }); - } // Hook long press method @@ -145,54 +110,126 @@ private static void hookLongPress(Activity activity, String viewName, View.OnLon public void mainActivity(ClassLoader classLoader) { // Hook onCreate of Instagram Main - XposedHelpers.findAndHookMethod("com.instagram.mainactivity.InstagramMainActivity", classLoader, "onCreate", android.os.Bundle.class, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - final Activity activity = (Activity) param.thisObject; - currentActivity = activity; - activity.runOnUiThread(() -> { - try { - setupHooks(activity); - addGhostEmojiNextToInbox(activity, isAnyGhostOptionEnabled()); - if (!FeatureFlags.showFeatureToasts || CustomToast.toastShown) return; - CustomToast.toastShown = true; - - new Handler(Looper.getMainLooper()).postDelayed(() -> { - StringBuilder sb = new StringBuilder("InstaEclipse Loaded 🎯\n"); - for (Map.Entry entry : FeatureStatusTracker.getStatus().entrySet()) { - sb.append(entry.getValue() ? "✅ " : "❌ ").append(entry.getKey()).append("\n"); - } - CustomToast.showCustomToast(activity.getApplicationContext(), sb.toString().trim()); - }, 1000); - } catch (Exception ignored) { + try { + // Precise search for the standard onCreate(Bundle) signature + var methods = Module.dexKitBridge.findMethod(create() + .matcher(org.luckypray.dexkit.query.matchers.MethodMatcher.create() + .declaredClass("com.instagram.mainactivity.InstagramMainActivity") + .name("onCreate") + .paramTypes("android.os.Bundle") + .returnType("void") + ) + ); + + // Fallback: If "onCreate" is renamed/obfuscated but still takes a Bundle + if (methods.isEmpty()) { + XposedBridge.log("(InstaEclipse): ⚠️ Specific onCreate not found, searching by signature..."); + methods = Module.dexKitBridge.findMethod(create() + .matcher(org.luckypray.dexkit.query.matchers.MethodMatcher.create() + .declaredClass("com.instagram.mainactivity.InstagramMainActivity") + .paramTypes("android.os.Bundle") + .returnType("void") + ) + ); + } + + if (!methods.isEmpty()) { + // Get the first match + var methodData = methods.get(0); + java.lang.reflect.Method targetMethod = methodData.getMethodInstance(classLoader); + XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) throws Throwable { + final Activity activity = (Activity) param.thisObject; + currentActivity = activity; + + // Use runOnUiThread to ensure we are touching the UI safely + activity.runOnUiThread(() -> { + try { + // 1. Initialize Hooks + setupHooks(activity); + + // 2. Delay UI injections slightly. + // Instagram's Main is complex; the Inbox/UI might not be inflated immediately. + new Handler(Looper.getMainLooper()).postDelayed(() -> { + try { + // Add the Ghost Emoji next to Inbox + addGhostEmojiNextToInbox(activity, isAnyGhostOptionEnabled()); + + // 3. Show Success Toast + if (FeatureFlags.showFeatureToasts && !CustomToast.toastShown) { + CustomToast.toastShown = true; + + StringBuilder sb = new StringBuilder("InstaEclipse Loaded 🎯\n"); + for (Map.Entry entry : FeatureStatusTracker.getStatus().entrySet()) { + sb.append(entry.getValue() ? "✅ " : "❌ ").append(entry.getKey()).append("\n"); + } + CustomToast.showCustomToast(activity.getApplicationContext(), sb.toString().trim()); + } + } catch (Exception innerE) { + XposedBridge.log("(InstaEclipse): UI Injection Error: " + innerE.getMessage()); + } + }, 1500); // 1.5s delay to let the UI settle + + } catch (Exception e) { + XposedBridge.log("(InstaEclipse): UI logic error in onCreate: " + e); + } + }); } }); + } else { + XposedBridge.log("(InstaEclipse): ❌ Failed to find any onCreate candidate in InstagramMainActivity"); } - }); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse): ❌ DexKit discovery failed: " + e.getMessage()); + } // Hook onResume - Instagram Main - XposedHelpers.findAndHookMethod("com.instagram.mainactivity.InstagramMainActivity", classLoader, "onResume", new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - final Activity activity = (Activity) param.thisObject; - currentActivity = activity; - activity.runOnUiThread(() -> { - try { - setupHooks(activity); - addGhostEmojiNextToInbox(activity, isAnyGhostOptionEnabled()); + try { + List candidates = Module.dexKitBridge.findMethod(org.luckypray.dexkit.query.FindMethod.create() + .matcher(org.luckypray.dexkit.query.matchers.MethodMatcher.create() + .declaredClass("com.instagram.mainactivity.InstagramMainActivity") + .modifiers(java.lang.reflect.Modifier.PUBLIC) + .paramCount(0) + .returnType("void") + ) + ); + + for (MethodData methodData : candidates) { + String methodName = methodData.getName(); + + // Skip constructors and static initializers + if (methodName.contains("") || methodName.contains("")) { + continue; + } - if (FeatureFlags.isImportingConfig) { - // De-bounce: flip it off first so it won't re-trigger on next onResume - FeatureFlags.isImportingConfig = false; - ConfigManager.importConfigFromClipboard(activity); - } - } catch (Exception ignored) { + // Filter by opcode size to find the substantial lifecycle method + if (methodData.getOpCodes().size() < 20) { + continue; + } + + java.lang.reflect.Method targetMethod = methodData.getMethodInstance(classLoader); + XposedBridge.hookMethod(targetMethod, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + final Activity activity = (Activity) param.thisObject; + currentActivity = activity; + activity.runOnUiThread(() -> { + try { + setupHooks(activity); + addGhostEmojiNextToInbox(activity, isAnyGhostOptionEnabled()); + } catch (Exception e) { + XposedBridge.log("(InstaEclipse) UI Error: " + e); + } + }); } }); + break; } - }); - + } catch (Throwable t) { + XposedBridge.log("(InstaEclipse): ❌ onResume discovery failed: " + t.getMessage()); + } // Hook getBottomSheetNavigator - Instagram Main BottomSheetHookUtil.hookBottomSheetNavigator(Module.dexKitBridge); @@ -236,4 +273,62 @@ private static void processSearchView(Activity activity, View view, String id) { } } -} + /** Registers a broadcast receiver in the Instagram process to handle config imports. */ + public static void registerConfigImportReceiver(android.content.Context context) { + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(android.content.Context ctx, Intent intent) { + String json = intent.getStringExtra("json_content"); + if (json != null && !json.isEmpty()) { + ConfigManager.importConfigFromJson(ctx, json); + } + } + }; + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, + new IntentFilter("ps.reso.instaeclipse.ACTION_IMPORT_CONFIG"), + android.content.Context.RECEIVER_EXPORTED); + } else { + context.registerReceiver(receiver, + new IntentFilter("ps.reso.instaeclipse.ACTION_IMPORT_CONFIG")); + } + } + + /** Registers a receiver in the Instagram process to restore settings from a backup JSON. */ + public static void registerSettingsRestoreReceiver(android.content.Context context) { + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(android.content.Context ctx, Intent intent) { + String json = intent.getStringExtra("json_content"); + if (json == null || json.isEmpty()) return; + new Thread(() -> { + try { + ps.reso.instaeclipse.utils.backup.SettingsBackupManager.fromJson(json); + SettingsManager.saveAllFlags(); + ps.reso.instaeclipse.utils.feature.FeatureManager.refreshFeatureStatus(); + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> Toast.makeText(ctx.getApplicationContext(), + "✅ " + I18n.t(ctx, R.string.ig_toast_settings_restored), Toast.LENGTH_SHORT).show()); + } catch (Exception e) { + Handler mainHandler = new Handler(Looper.getMainLooper()); + mainHandler.post(() -> Toast.makeText(ctx.getApplicationContext(), + "❌ " + I18n.t(ctx, R.string.ig_toast_restore_failed, e.getMessage()), Toast.LENGTH_LONG).show()); + } + }).start(); + } + }; + try { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, + new IntentFilter("ps.reso.instaeclipse.ACTION_RESTORE_SETTINGS"), + android.content.Context.RECEIVER_EXPORTED); + } else { + context.registerReceiver(receiver, + new IntentFilter("ps.reso.instaeclipse.ACTION_RESTORE_SETTINGS")); + } + } catch (Throwable e) { + XposedBridge.log("(InstaEclipse | RestoreReceiver): ❌ " + e.getMessage()); + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/mods/ui/utils/BottomSheetHookUtil.java b/app/src/main/java/ps/reso/instaeclipse/mods/ui/utils/BottomSheetHookUtil.java index 4a7a0977..c36c471c 100644 --- a/app/src/main/java/ps/reso/instaeclipse/mods/ui/utils/BottomSheetHookUtil.java +++ b/app/src/main/java/ps/reso/instaeclipse/mods/ui/utils/BottomSheetHookUtil.java @@ -19,29 +19,31 @@ import de.robv.android.xposed.XC_MethodHook; import de.robv.android.xposed.XposedBridge; import ps.reso.instaeclipse.Xposed.Module; +import ps.reso.instaeclipse.utils.core.DexKitCache; import ps.reso.instaeclipse.utils.ghost.GhostModeUtils; public class BottomSheetHookUtil { + private static final String CACHE_KEY = "BottomSheet"; + public static void hookBottomSheetNavigator(DexKitBridge bridge) { + // Try cache first + if (DexKitCache.isCacheValid()) { + Method cached = DexKitCache.loadMethod(CACHE_KEY, Module.hostClassLoader); + if (cached != null) { + hookMethod(cached); + return; + } + } + try { List methods = bridge.findMethod( FindMethod.create() - .matcher( - MethodMatcher.create() - .usingStrings("BottomSheetConstants") - ) + .matcher(MethodMatcher.create().usingStrings("BottomSheetConstants")) ); - if (methods.isEmpty()) { - return; - } - for (MethodData method : methods) { - // ✅ Filter to only methods inside the InstagramMainActivity class - if (!method.getClassName().equals("com.instagram.mainactivity.InstagramMainActivity")) { - continue; - } + if (!method.getClassName().equals("com.instagram.mainactivity.InstagramMainActivity")) continue; Method reflectMethod; try { @@ -54,30 +56,12 @@ public static void hookBottomSheetNavigator(DexKitBridge bridge) { String returnType = String.valueOf(method.getReturnType()); ClassDataList paramTypes = method.getParamTypes(); - // ✅ Match: final, non-static, non-void return, 0-args if (!Modifier.isStatic(modifiers) && Modifier.isFinal(modifiers) && !returnType.contains("void") && paramTypes.size() == 0) { - - XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { - @Override - protected void afterHookedMethod(MethodHookParam param) { - final Activity activity = getCurrentActivity(); - if (activity != null) { - activity.runOnUiThread(() -> { - try { - setupHooks(activity); - addGhostEmojiNextToInbox(activity, GhostModeUtils.isGhostModeActive()); - } catch (Exception ignored) { - } - }); - } - } - }); - - XposedBridge.log("(InstaEclipse | BottomSheet): ✅ Hooked: " + - method.getClassName() + "." + method.getName()); + DexKitCache.saveMethod(CACHE_KEY, reflectMethod); + hookMethod(reflectMethod); return; } } @@ -86,5 +70,24 @@ protected void afterHookedMethod(MethodHookParam param) { XposedBridge.log("(InstaEclipse | BottomSheet): ❌ DexKit exception: " + e.getMessage()); } } + + private static void hookMethod(Method reflectMethod) { + XposedBridge.hookMethod(reflectMethod, new XC_MethodHook() { + @Override + protected void afterHookedMethod(MethodHookParam param) { + final Activity activity = getCurrentActivity(); + if (activity != null) { + activity.runOnUiThread(() -> { + try { + setupHooks(activity); + addGhostEmojiNextToInbox(activity, GhostModeUtils.isGhostModeActive()); + } catch (Exception ignored) { + } + }); + } + } + }); + XposedBridge.log("(InstaEclipse | BottomSheet): ✅ Hooked: " + reflectMethod.getDeclaringClass().getName() + "." + reflectMethod.getName()); + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/backup/SettingsBackupManager.java b/app/src/main/java/ps/reso/instaeclipse/utils/backup/SettingsBackupManager.java new file mode 100644 index 00000000..ec140a90 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/utils/backup/SettingsBackupManager.java @@ -0,0 +1,144 @@ +package ps.reso.instaeclipse.utils.backup; + +import org.json.JSONException; +import org.json.JSONObject; + +import ps.reso.instaeclipse.utils.feature.FeatureFlags; + +public class SettingsBackupManager { + + private static final int VERSION = 1; + + /** Serialises every known FeatureFlag into a versioned JSON string. */ + public static String toJson() throws JSONException { + JSONObject s = new JSONObject(); + + // Developer + s.put("isDevEnabled", FeatureFlags.isDevEnabled); + s.put("removeBuildExpiredPopup", FeatureFlags.removeBuildExpiredPopup); + + // Ghost Mode + s.put("isGhostSeen", FeatureFlags.isGhostSeen); + s.put("isGhostTyping", FeatureFlags.isGhostTyping); + s.put("isGhostScreenshot", FeatureFlags.isGhostScreenshot); + s.put("isGhostViewOnce", FeatureFlags.isGhostViewOnce); + s.put("enableUnlimitedReplays", FeatureFlags.enableUnlimitedReplays); + s.put("isGhostStory", FeatureFlags.isGhostStory); + s.put("isGhostLive", FeatureFlags.isGhostLive); + s.put("allowScreenshots", FeatureFlags.allowScreenshots); + s.put("keepEphemeralMessages", FeatureFlags.keepEphemeralMessages); + + s.put("permanentViewMode", FeatureFlags.permanentViewMode); + + // Quick Toggles + s.put("quickToggleSeen", FeatureFlags.quickToggleSeen); + s.put("quickToggleTyping", FeatureFlags.quickToggleTyping); + s.put("quickToggleScreenshot", FeatureFlags.quickToggleScreenshot); + s.put("quickToggleViewOnce", FeatureFlags.quickToggleViewOnce); + s.put("quickToggleStory", FeatureFlags.quickToggleStory); + s.put("quickToggleLive", FeatureFlags.quickToggleLive); + s.put("quickToggleEphemeral", FeatureFlags.quickToggleEphemeral); + s.put("quickToggleReplays", FeatureFlags.quickToggleReplays); + s.put("quickTogglePermanentView",FeatureFlags.quickTogglePermanentView); + s.put("quickToggleAllowScreenshots", FeatureFlags.quickToggleAllowScreenshots); + + // Ads + s.put("isAdBlockEnabled", FeatureFlags.isAdBlockEnabled); + s.put("isAnalyticsBlocked", FeatureFlags.isAnalyticsBlocked); + s.put("disableTrackingLinks", FeatureFlags.disableTrackingLinks); + + // Distraction Free + s.put("isExtremeMode", FeatureFlags.isExtremeMode); + s.put("disableStories", FeatureFlags.disableStories); + s.put("disableFeed", FeatureFlags.disableFeed); + s.put("disableReels", FeatureFlags.disableReels); + s.put("disableReelsExceptDM", FeatureFlags.disableReelsExceptDM); + s.put("disableExplore", FeatureFlags.disableExplore); + s.put("disableComments", FeatureFlags.disableComments); + s.put("disableDiscoverPeople", FeatureFlags.disableDiscoverPeople); + + // Miscellaneous + s.put("disableStoryFlipping", FeatureFlags.disableStoryFlipping); + s.put("disableVideoAutoPlay", FeatureFlags.disableVideoAutoPlay); + s.put("disableRepost", FeatureFlags.disableRepost); + s.put("showFollowerToast", FeatureFlags.showFollowerToast); + s.put("showFeatureToasts", FeatureFlags.showFeatureToasts); + s.put("enableStoryMentions", FeatureFlags.enableStoryMentions); + + // Downloader + s.put("enablePostDownload", FeatureFlags.enablePostDownload); + s.put("enableStoryDownload", FeatureFlags.enableStoryDownload); + s.put("enableReelDownload", FeatureFlags.enableReelDownload); + s.put("enableProfileDownload", FeatureFlags.enableProfileDownload); + s.put("downloaderUsernameFolder",FeatureFlags.downloaderUsernameFolder); + s.put("downloaderAddTimestamp", FeatureFlags.downloaderAddTimestamp); + + JSONObject root = new JSONObject(); + root.put("version", VERSION); + root.put("settings", s); + return root.toString(2); + } + + /** + * Applies a backup JSON string to the in-memory FeatureFlags. + * Supports both the versioned {"version":1,"settings":{...}} format + * and a flat {key:value} format for forward-compatibility. + * Unknown keys are silently ignored so older backups work on newer builds. + */ + public static void fromJson(String json) throws JSONException { + JSONObject root = new JSONObject(json); + JSONObject s = root.has("settings") ? root.getJSONObject("settings") : root; + + if (s.has("isDevEnabled")) FeatureFlags.isDevEnabled = s.getBoolean("isDevEnabled"); + if (s.has("removeBuildExpiredPopup")) FeatureFlags.removeBuildExpiredPopup = s.getBoolean("removeBuildExpiredPopup"); + + if (s.has("isGhostSeen")) FeatureFlags.isGhostSeen = s.getBoolean("isGhostSeen"); + if (s.has("isGhostTyping")) FeatureFlags.isGhostTyping = s.getBoolean("isGhostTyping"); + if (s.has("isGhostScreenshot")) FeatureFlags.isGhostScreenshot = s.getBoolean("isGhostScreenshot"); + if (s.has("isGhostViewOnce")) FeatureFlags.isGhostViewOnce = s.getBoolean("isGhostViewOnce"); + if (s.has("enableUnlimitedReplays")) FeatureFlags.enableUnlimitedReplays = s.getBoolean("enableUnlimitedReplays"); + if (s.has("isGhostStory")) FeatureFlags.isGhostStory = s.getBoolean("isGhostStory"); + if (s.has("isGhostLive")) FeatureFlags.isGhostLive = s.getBoolean("isGhostLive"); + if (s.has("allowScreenshots")) FeatureFlags.allowScreenshots = s.getBoolean("allowScreenshots"); + if (s.has("keepEphemeralMessages")) FeatureFlags.keepEphemeralMessages = s.getBoolean("keepEphemeralMessages"); + if (s.has("permanentViewMode")) FeatureFlags.permanentViewMode = s.getBoolean("permanentViewMode"); + + if (s.has("quickToggleSeen")) FeatureFlags.quickToggleSeen = s.getBoolean("quickToggleSeen"); + if (s.has("quickToggleTyping")) FeatureFlags.quickToggleTyping = s.getBoolean("quickToggleTyping"); + if (s.has("quickToggleScreenshot")) FeatureFlags.quickToggleScreenshot = s.getBoolean("quickToggleScreenshot"); + if (s.has("quickToggleViewOnce")) FeatureFlags.quickToggleViewOnce = s.getBoolean("quickToggleViewOnce"); + if (s.has("quickToggleStory")) FeatureFlags.quickToggleStory = s.getBoolean("quickToggleStory"); + if (s.has("quickToggleLive")) FeatureFlags.quickToggleLive = s.getBoolean("quickToggleLive"); + if (s.has("quickToggleEphemeral")) FeatureFlags.quickToggleEphemeral = s.getBoolean("quickToggleEphemeral"); + if (s.has("quickToggleReplays")) FeatureFlags.quickToggleReplays = s.getBoolean("quickToggleReplays"); + if (s.has("quickTogglePermanentView")) FeatureFlags.quickTogglePermanentView = s.getBoolean("quickTogglePermanentView"); + if (s.has("quickToggleAllowScreenshots")) FeatureFlags.quickToggleAllowScreenshots = s.getBoolean("quickToggleAllowScreenshots"); + + if (s.has("isAdBlockEnabled")) FeatureFlags.isAdBlockEnabled = s.getBoolean("isAdBlockEnabled"); + if (s.has("isAnalyticsBlocked")) FeatureFlags.isAnalyticsBlocked = s.getBoolean("isAnalyticsBlocked"); + if (s.has("disableTrackingLinks")) FeatureFlags.disableTrackingLinks = s.getBoolean("disableTrackingLinks"); + + if (s.has("isExtremeMode")) FeatureFlags.isExtremeMode = s.getBoolean("isExtremeMode"); + if (s.has("disableStories")) FeatureFlags.disableStories = s.getBoolean("disableStories"); + if (s.has("disableFeed")) FeatureFlags.disableFeed = s.getBoolean("disableFeed"); + if (s.has("disableReels")) FeatureFlags.disableReels = s.getBoolean("disableReels"); + if (s.has("disableReelsExceptDM")) FeatureFlags.disableReelsExceptDM = s.getBoolean("disableReelsExceptDM"); + if (s.has("disableExplore")) FeatureFlags.disableExplore = s.getBoolean("disableExplore"); + if (s.has("disableComments")) FeatureFlags.disableComments = s.getBoolean("disableComments"); + if (s.has("disableDiscoverPeople")) FeatureFlags.disableDiscoverPeople = s.getBoolean("disableDiscoverPeople"); + + if (s.has("disableStoryFlipping")) FeatureFlags.disableStoryFlipping = s.getBoolean("disableStoryFlipping"); + if (s.has("disableVideoAutoPlay")) FeatureFlags.disableVideoAutoPlay = s.getBoolean("disableVideoAutoPlay"); + if (s.has("disableRepost")) FeatureFlags.disableRepost = s.getBoolean("disableRepost"); + if (s.has("showFollowerToast")) FeatureFlags.showFollowerToast = s.getBoolean("showFollowerToast"); + if (s.has("showFeatureToasts")) FeatureFlags.showFeatureToasts = s.getBoolean("showFeatureToasts"); + if (s.has("enableStoryMentions")) FeatureFlags.enableStoryMentions = s.getBoolean("enableStoryMentions"); + + if (s.has("enablePostDownload")) FeatureFlags.enablePostDownload = s.getBoolean("enablePostDownload"); + if (s.has("enableStoryDownload")) FeatureFlags.enableStoryDownload = s.getBoolean("enableStoryDownload"); + if (s.has("enableReelDownload")) FeatureFlags.enableReelDownload = s.getBoolean("enableReelDownload"); + if (s.has("enableProfileDownload")) FeatureFlags.enableProfileDownload = s.getBoolean("enableProfileDownload"); + if (s.has("downloaderUsernameFolder")) FeatureFlags.downloaderUsernameFolder = s.getBoolean("downloaderUsernameFolder"); + if (s.has("downloaderAddTimestamp")) FeatureFlags.downloaderAddTimestamp = s.getBoolean("downloaderAddTimestamp"); + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/core/CommonUtils.java b/app/src/main/java/ps/reso/instaeclipse/utils/core/CommonUtils.java index 4d1bcd50..c15bffb3 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/core/CommonUtils.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/core/CommonUtils.java @@ -1,9 +1,49 @@ package ps.reso.instaeclipse.utils.core; +import java.util.Arrays; +import java.util.List; + public class CommonUtils { public static final String IG_PACKAGE_NAME = "com.instagram.android"; public static final String MY_PACKAGE_NAME = "ps.reso.instaeclipse"; + /** All Instagram packages this module hooks into. */ + public static final List SUPPORTED_PACKAGES = Arrays.asList( + "com.instagram.android", + "com.instagold.android", + "com.instaflux.app", + "com.myinsta.android", + "cc.honista.app", + "com.instaprime.android", + "com.instafel.android", + "com.instadm.android", + "com.dfistagram.android", + "com.Instander.android", + "com.aero.instagram", + "com.instapro.android", + "com.instaflow.android", + "com.instagram1.android", + "com.instagram2.android", + "com.instagramclone.android", + "com.instaclone.android" + ); + + /** + * Returns a human-readable variant label for a package name. + * "com.instagram.android" → "Official" + * "com.instaflow.android" → "Instaflow" + * "cc.honista.app" → "Honista" + */ + public static String getVariantLabel(String packageName) { + if (IG_PACKAGE_NAME.equals(packageName)) return "Official"; + String[] parts = packageName.split("\\."); + // Use the most descriptive segment: skip generic TLDs and short segments + String best = parts.length >= 2 ? parts[1] : packageName; + // If second segment is very short or generic, try third + if (best.length() <= 2 && parts.length >= 3) best = parts[2]; + return Character.toUpperCase(best.charAt(0)) + best.substring(1).toLowerCase(); + } + /* Dev Purposes public static final String USER_SESSION_CLASS = "com.instagram.common.session.UserSession"; diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/core/DexKitCache.java b/app/src/main/java/ps/reso/instaeclipse/utils/core/DexKitCache.java new file mode 100644 index 00000000..0a7517c2 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/utils/core/DexKitCache.java @@ -0,0 +1,178 @@ +package ps.reso.instaeclipse.utils.core; + +import android.content.Context; +import android.content.SharedPreferences; + +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; + +import de.robv.android.xposed.XposedBridge; + +/** + * Caches DexKit-resolved method signatures in SharedPreferences, keyed by + * Instagram's version code. On the first run (or after an IG update), DexKit + * runs as normal and saves each resolved {@link Method} descriptor. On every + * subsequent launch of the same IG version, the saved descriptor is used to + * look up the {@link Method} via reflection — skipping DexKit entirely. + * + *

Usage pattern (single method)

+ *
+ *   if (DexKitCache.isCacheValid()) {
+ *       Method m = DexKitCache.loadMethod("MyHook", classLoader);
+ *       if (m != null) { hookIt(m); return; }
+ *   }
+ *   // DexKit path
+ *   Method m = findViaDexKit(bridge);
+ *   if (m != null) {
+ *       DexKitCache.saveMethod("MyHook", m);
+ *       hookIt(m);
+ *   }
+ * 
+ * + *

Separator

+ * Method descriptors are encoded as {@code className + NUL + methodName + NUL + descriptor}. + * The NUL character ({@code \u0000}) never appears in class/method names or JVM descriptors. + */ +public class DexKitCache { + + private static final String PREF_NAME = "instaeclipse_dexkit_cache"; + private static final String KEY_VER = "_v"; + private static final char SEP = '\u0000'; + + private static SharedPreferences prefs; + private static boolean cacheValid = false; + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + /** + * Must be called once per app-attach, before any hooks run. + * Compares {@code igVersion} (e.g. the IG long version code as a string) + * with the stored version; clears the cache when they differ. + */ + public static void init(Context context, String igVersion) { + prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE); + String stored = prefs.getString(KEY_VER, ""); + if (stored.equals(igVersion)) { + cacheValid = true; + XposedBridge.log("(DexKitCache) Cache valid for IG " + igVersion); + } else { + cacheValid = false; + prefs.edit().clear().putString(KEY_VER, igVersion).apply(); + XposedBridge.log("(DexKitCache) Version " + stored + " → " + igVersion + ", cache cleared"); + } + } + + /** Returns {@code true} when the stored cache was built for the currently-running IG version. */ + public static boolean isCacheValid() { + return cacheValid; + } + + // ── Single method ──────────────────────────────────────────────────────── + + public static void saveMethod(String key, Method m) { + if (prefs == null || m == null) return; + prefs.edit().putString("m_" + key, encode(m)).apply(); + } + + /** Returns {@code null} on any decode failure (treat as a cache miss). */ + public static Method loadMethod(String key, ClassLoader loader) { + if (prefs == null) return null; + String val = prefs.getString("m_" + key, null); + return val != null ? decode(val, loader) : null; + } + + // ── Multiple methods ───────────────────────────────────────────────────── + + public static void saveMethods(String key, List methods) { + if (prefs == null || methods == null) return; + SharedPreferences.Editor ed = prefs.edit(); + ed.putInt("mc_" + key, methods.size()); + for (int i = 0; i < methods.size(); i++) { + ed.putString("m_" + key + "_" + i, encode(methods.get(i))); + } + ed.apply(); + } + + /** + * Returns the cached list, or {@code null} if any entry is missing / cannot + * be decoded (so the whole DexKit search is re-run in that case). + */ + public static List loadMethods(String key, ClassLoader loader) { + if (prefs == null) return null; + int count = prefs.getInt("mc_" + key, -1); + if (count < 0) return null; + List result = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + String val = prefs.getString("m_" + key + "_" + i, null); + if (val == null) return null; + Method m = decode(val, loader); + if (m == null) return null; + result.add(m); + } + return result; + } + + // ── Arbitrary strings (class names, etc.) ──────────────────────────────── + + public static void saveString(String key, String value) { + if (prefs == null) return; + prefs.edit().putString("s_" + key, value).apply(); + } + + public static String loadString(String key) { + if (prefs == null) return null; + return prefs.getString("s_" + key, null); + } + + // ── Encoding / Decoding ────────────────────────────────────────────────── + + private static String encode(Method m) { + return m.getDeclaringClass().getName() + SEP + m.getName() + SEP + descriptor(m); + } + + private static Method decode(String encoded, ClassLoader loader) { + try { + int i1 = encoded.indexOf(SEP); + int i2 = encoded.indexOf(SEP, i1 + 1); + if (i1 < 0 || i2 < 0) return null; + String className = encoded.substring(0, i1); + String methodName = encoded.substring(i1 + 1, i2); + String desc = encoded.substring(i2 + 1); + Class clazz = Class.forName(className, false, loader); + for (Method m : clazz.getDeclaredMethods()) { + if (m.getName().equals(methodName) && descriptor(m).equals(desc)) { + m.setAccessible(true); + return m; + } + } + } catch (Throwable ignored) {} + return null; + } + + /** Builds a JVM method descriptor string, e.g. {@code (Ljava/lang/String;I)V}. */ + private static String descriptor(Method m) { + StringBuilder sb = new StringBuilder("("); + for (Class p : m.getParameterTypes()) typeDesc(sb, p); + sb.append(")"); + typeDesc(sb, m.getReturnType()); + return sb.toString(); + } + + private static void typeDesc(StringBuilder sb, Class t) { + while (t.isArray()) { sb.append('['); t = t.getComponentType(); } + if (t.isPrimitive()) { + if (t == void.class) sb.append('V'); + else if (t == boolean.class) sb.append('Z'); + else if (t == byte.class) sb.append('B'); + else if (t == char.class) sb.append('C'); + else if (t == short.class) sb.append('S'); + else if (t == int.class) sb.append('I'); + else if (t == long.class) sb.append('J'); + else if (t == float.class) sb.append('F'); + else if (t == double.class) sb.append('D'); + } else { + sb.append('L').append(t.getName().replace('.', '/')).append(';'); + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/core/SettingsManager.java b/app/src/main/java/ps/reso/instaeclipse/utils/core/SettingsManager.java index e01e1cf4..89b1eca2 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/core/SettingsManager.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/core/SettingsManager.java @@ -27,8 +27,12 @@ public static void saveAllFlags() { editor.putBoolean("isGhostTyping", FeatureFlags.isGhostTyping); editor.putBoolean("isGhostScreenshot", FeatureFlags.isGhostScreenshot); editor.putBoolean("isGhostViewOnce", FeatureFlags.isGhostViewOnce); + editor.putBoolean("enableUnlimitedReplays", FeatureFlags.enableUnlimitedReplays); editor.putBoolean("isGhostStory", FeatureFlags.isGhostStory); editor.putBoolean("isGhostLive", FeatureFlags.isGhostLive); + editor.putBoolean("allowScreenshots", FeatureFlags.allowScreenshots); + editor.putBoolean("keepEphemeralMessages", FeatureFlags.keepEphemeralMessages); + editor.putBoolean("permanentViewMode", FeatureFlags.permanentViewMode); // Quick Toggles editor.putBoolean("quickToggleSeen", FeatureFlags.quickToggleSeen); @@ -37,6 +41,10 @@ public static void saveAllFlags() { editor.putBoolean("quickToggleViewOnce", FeatureFlags.quickToggleViewOnce); editor.putBoolean("quickToggleStory", FeatureFlags.quickToggleStory); editor.putBoolean("quickToggleLive", FeatureFlags.quickToggleLive); + editor.putBoolean("quickToggleEphemeral", FeatureFlags.quickToggleEphemeral); + editor.putBoolean("quickToggleReplays", FeatureFlags.quickToggleReplays); + editor.putBoolean("quickTogglePermanentView", FeatureFlags.quickTogglePermanentView); + editor.putBoolean("quickToggleAllowScreenshots", FeatureFlags.quickToggleAllowScreenshots); // Distraction Free editor.putBoolean("isExtremeMode", FeatureFlags.isExtremeMode); @@ -60,6 +68,18 @@ public static void saveAllFlags() { editor.putBoolean("disableRepost", FeatureFlags.disableRepost); editor.putBoolean("showFollowerToast", FeatureFlags.showFollowerToast); editor.putBoolean("showFeatureToasts", FeatureFlags.showFeatureToasts); + editor.putBoolean("enableStoryMentions", FeatureFlags.enableStoryMentions); + editor.putBoolean("disableDiscoverPeople", FeatureFlags.disableDiscoverPeople); + editor.putBoolean("removeBuildExpiredPopup", FeatureFlags.removeBuildExpiredPopup); + editor.putBoolean("enableCopyComment", FeatureFlags.enableCopyComment); + editor.putBoolean("enablePostDownload", FeatureFlags.enablePostDownload); + editor.putBoolean("enableStoryDownload", FeatureFlags.enableStoryDownload); + editor.putBoolean("enableReelDownload", FeatureFlags.enableReelDownload); + editor.putBoolean("enableProfileDownload", FeatureFlags.enableProfileDownload); + editor.putBoolean("downloaderUsernameFolder", FeatureFlags.downloaderUsernameFolder); + editor.putBoolean("downloaderAddTimestamp", FeatureFlags.downloaderAddTimestamp); + editor.putString("downloaderCustomPath", FeatureFlags.downloaderCustomPath); + editor.putString("downloaderCustomUri", FeatureFlags.downloaderCustomUri); editor.apply(); @@ -79,8 +99,12 @@ public static void loadAllFlags(Context context) { FeatureFlags.isGhostTyping = prefs.getBoolean("isGhostTyping", false); FeatureFlags.isGhostScreenshot = prefs.getBoolean("isGhostScreenshot", false); FeatureFlags.isGhostViewOnce = prefs.getBoolean("isGhostViewOnce", false); + FeatureFlags.enableUnlimitedReplays = prefs.getBoolean("enableUnlimitedReplays", false); FeatureFlags.isGhostStory = prefs.getBoolean("isGhostStory", false); FeatureFlags.isGhostLive = prefs.getBoolean("isGhostLive", false); + FeatureFlags.allowScreenshots = prefs.getBoolean("allowScreenshots", false); + FeatureFlags.keepEphemeralMessages = prefs.getBoolean("keepEphemeralMessages", false); + FeatureFlags.permanentViewMode = prefs.getBoolean("permanentViewMode", false); // Quick Toggles FeatureFlags.quickToggleSeen = prefs.getBoolean("quickToggleSeen", false); @@ -89,6 +113,10 @@ public static void loadAllFlags(Context context) { FeatureFlags.quickToggleViewOnce = prefs.getBoolean("quickToggleViewOnce", false); FeatureFlags.quickToggleStory = prefs.getBoolean("quickToggleStory", false); FeatureFlags.quickToggleLive = prefs.getBoolean("quickToggleLive", false); + FeatureFlags.quickToggleEphemeral = prefs.getBoolean("quickToggleEphemeral", false); + FeatureFlags.quickToggleReplays = prefs.getBoolean("quickToggleReplays", false); + FeatureFlags.quickTogglePermanentView = prefs.getBoolean("quickTogglePermanentView", false); + FeatureFlags.quickToggleAllowScreenshots = prefs.getBoolean("quickToggleAllowScreenshots", false); // Distraction Free FeatureFlags.isExtremeMode = prefs.getBoolean("isExtremeMode", false); @@ -112,6 +140,18 @@ public static void loadAllFlags(Context context) { FeatureFlags.disableRepost = prefs.getBoolean("disableRepost", false); FeatureFlags.showFollowerToast = prefs.getBoolean("showFollowerToast", false); FeatureFlags.showFeatureToasts = prefs.getBoolean("showFeatureToasts", false); + FeatureFlags.enableStoryMentions = prefs.getBoolean("enableStoryMentions", false); + FeatureFlags.disableDiscoverPeople = prefs.getBoolean("disableDiscoverPeople", false); + FeatureFlags.removeBuildExpiredPopup = prefs.getBoolean("removeBuildExpiredPopup", false); + FeatureFlags.enableCopyComment = prefs.getBoolean("enableCopyComment", false); + FeatureFlags.enablePostDownload = prefs.getBoolean("enablePostDownload", false); + FeatureFlags.enableStoryDownload = prefs.getBoolean("enableStoryDownload", false); + FeatureFlags.enableReelDownload = prefs.getBoolean("enableReelDownload", false); + FeatureFlags.enableProfileDownload = prefs.getBoolean("enableProfileDownload", false); + FeatureFlags.downloaderUsernameFolder = prefs.getBoolean("downloaderUsernameFolder", false); + FeatureFlags.downloaderAddTimestamp = prefs.getBoolean("downloaderAddTimestamp", false); + FeatureFlags.downloaderCustomPath = prefs.getString("downloaderCustomPath", ""); + FeatureFlags.downloaderCustomUri = prefs.getString("downloaderCustomUri", ""); FeatureManager.refreshFeatureStatus(); } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/dialog/DialogUtils.java b/app/src/main/java/ps/reso/instaeclipse/utils/dialog/DialogUtils.java index d515965f..b3d4845d 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/dialog/DialogUtils.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/dialog/DialogUtils.java @@ -12,26 +12,29 @@ import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.GradientDrawable; import android.graphics.drawable.StateListDrawable; -import android.view.ContextThemeWrapper; import android.view.Gravity; import android.view.View; -import android.widget.Button; +import android.view.ViewGroup; +import android.view.Window; +import android.widget.CompoundButton; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.Switch; import android.widget.TextView; import android.widget.Toast; +import java.io.BufferedReader; import java.io.File; -import java.util.Objects; +import java.io.FileReader; import de.robv.android.xposed.XposedBridge; -import ps.reso.instaeclipse.mods.devops.config.ConfigManager; +import ps.reso.instaeclipse.R; import ps.reso.instaeclipse.mods.ghost.ui.GhostEmojiManager; import ps.reso.instaeclipse.mods.ui.UIHookManager; import ps.reso.instaeclipse.utils.core.SettingsManager; import ps.reso.instaeclipse.utils.feature.FeatureFlags; import ps.reso.instaeclipse.utils.ghost.GhostModeUtils; +import ps.reso.instaeclipse.utils.i18n.I18n; public class DialogUtils { @@ -40,26 +43,24 @@ public class DialogUtils { @SuppressLint("UseCompatLoadingForDrawables") public static void showEclipseOptionsDialog(Context context) { SettingsManager.init(context); - Context themedContext = new ContextThemeWrapper(context, android.R.style.Theme_Material_Dialog_Alert); - LinearLayout mainLayout = buildMainMenuLayout(themedContext); - ScrollView scrollView = new ScrollView(themedContext); + LinearLayout mainLayout = buildMainMenuLayout(context); + ScrollView scrollView = new ScrollView(context); scrollView.addView(mainLayout); if (currentDialog != null && currentDialog.isShowing()) { - currentDialog.dismiss(); + try { currentDialog.dismiss(); } catch (Exception ignored) {} } + currentDialog = null; - currentDialog = new AlertDialog.Builder(themedContext).setView(scrollView).setTitle(null).setCancelable(true).create(); - - Objects.requireNonNull(currentDialog.getWindow()).setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - + currentDialog = createBottomSheetDialog(context, scrollView); currentDialog.show(); } public static void showSimpleDialog(Context context, String title, String message) { try { - new AlertDialog.Builder(context).setTitle(title).setMessage(message).setPositiveButton("OK", null).show(); + new AlertDialog.Builder(context).setTitle(title).setMessage(message) + .setPositiveButton(I18n.t(context, R.string.ig_dialog_ok), null).show(); } catch (Exception e) { // handle UI crash fallback } @@ -69,20 +70,23 @@ public static void showSimpleDialog(Context context, String title, String messag private static LinearLayout buildMainMenuLayout(Context context) { LinearLayout mainLayout = new LinearLayout(context); mainLayout.setOrientation(LinearLayout.VERTICAL); - mainLayout.setPadding(40, 40, 40, 20); + mainLayout.setPadding(0, 0, 0, 0); GradientDrawable background = new GradientDrawable(); - background.setColor(Color.parseColor("#262626")); - background.setCornerRadius(32); + background.setColor(Color.parseColor("#1C1C1E")); + background.setCornerRadii(new float[]{40, 40, 40, 40, 0, 0, 0, 0}); mainLayout.setBackground(background); + mainLayout.addView(createDragHandle(context)); + // Title TextView title = new TextView(context); - title.setText("InstaEclipse 🌘"); + title.setText(I18n.t(context, R.string.ig_dialog_title)); title.setTextColor(Color.WHITE); - title.setTextSize(22); + title.setTextSize(20); + title.setTypeface(null, Typeface.BOLD); title.setGravity(Gravity.CENTER); - title.setPadding(0, 20, 0, 20); + title.setPadding(40, 8, 40, 20); mainLayout.addView(title); mainLayout.addView(createDivider(context)); @@ -90,55 +94,61 @@ private static LinearLayout buildMainMenuLayout(Context context) { // Now building menu manually // 0 - Developer Options => OPEN PAGE - mainLayout.addView(createClickableSection(context, "🎛 Developer Options", () -> showDevOptions(context))); + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_dev_options), () -> showDevOptions(context))); // 1 - Ghost Mode Settings => OPEN PAGE - mainLayout.addView(createClickableSection(context, "👻 Ghost Mode Settings", () -> showGhostOptions(context))); + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_ghost_settings), () -> showGhostOptions(context))); // 2 - Ad/Analytics Block => OPEN PAGE - mainLayout.addView(createClickableSection(context, "🛡 Ad/Analytics Block", () -> showAdOptions(context))); + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_ad_analytics), () -> showAdOptions(context))); // 3 - Distraction-Free Instagram => OPEN PAGE - mainLayout.addView(createClickableSection(context, "🧘 Distraction-Free Instagram", () -> showDistractionOptions(context))); + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_distraction_free), () -> showDistractionOptions(context))); // 4 - Misc Features => OPEN PAGE - mainLayout.addView(createClickableSection(context, "⚙ Misc Features", () -> showMiscOptions(context))); + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_misc), () -> showMiscOptions(context))); + + // 5 - Downloader => OPEN PAGE + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_downloader), () -> showDownloaderOptions(context))); + + // 7 - Backup & Restore => OPEN PAGE + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_backup_restore), () -> showBackupRestoreOptions(context))); - // 5 - About => OPEN PAGE - mainLayout.addView(createClickableSection(context, "ℹ️ About", () -> showAboutDialog(context))); + // 8 - About => OPEN PAGE + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_about), () -> showAboutDialog(context))); - // 6 - Restart Instagram => OPEN PAGE - mainLayout.addView(createClickableSection(context, "🔁 Restart App", () -> showRestartSection(context))); + // 9 - Restart Instagram => OPEN PAGE + mainLayout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_menu_restart), () -> showRestartSection(context))); mainLayout.addView(createDivider(context)); // Footer Credit TextView footer = new TextView(context); footer.setText("@reso7200"); - footer.setTextColor(Color.GRAY); - footer.setTextSize(14); - footer.setPadding(0, 30, 0, 10); + footer.setTextColor(Color.parseColor("#8E8E93")); + footer.setTextSize(13); + footer.setPadding(40, 20, 40, 4); footer.setGravity(Gravity.CENTER_HORIZONTAL); mainLayout.addView(footer); // Embedded Close Button TextView closeButton = new TextView(context); - closeButton.setText("❌ Close"); - closeButton.setTextColor(Color.WHITE); + closeButton.setText(I18n.t(context, R.string.ig_dialog_close)); + closeButton.setTextColor(Color.parseColor("#FF453A")); closeButton.setTextSize(16); - closeButton.setPadding(20, 30, 20, 30); + closeButton.setPadding(40, 20, 40, 40); closeButton.setGravity(Gravity.CENTER); + closeButton.setTypeface(null, Typeface.BOLD); StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#40FFFFFF"))); + states.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#20FF453A"))); states.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); closeButton.setBackground(states); closeButton.setOnClickListener(v -> { - if (currentDialog != null) currentDialog.dismiss(); + if (currentDialog != null) { try { currentDialog.dismiss(); } catch (Exception ignored) {} currentDialog = null; } }); - mainLayout.addView(createDivider(context)); // Divider above close button mainLayout.addView(closeButton); SettingsManager.saveAllFlags(); @@ -156,14 +166,24 @@ private static void showGhostQuickToggleOptions(Context context) { LinearLayout layout = createSwitchLayout(context); // Create switches for customizing what gets toggled - Switch[] toggleSwitches = new Switch[]{createSwitch(context, "Include Hide Seen", FeatureFlags.quickToggleSeen), createSwitch(context, "Include Hide Typing", FeatureFlags.quickToggleTyping), createSwitch(context, "Include Disable Screenshot Detection", FeatureFlags.quickToggleScreenshot), createSwitch(context, "Include Hide View Once", FeatureFlags.quickToggleViewOnce), createSwitch(context, "Include Hide Story Seen", FeatureFlags.quickToggleStory), createSwitch(context, "Include Hide Live Seen", FeatureFlags.quickToggleLive)}; + ToggleRow[] toggleSwitches = new ToggleRow[]{ + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_hide_seen), FeatureFlags.quickToggleSeen), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_hide_typing), FeatureFlags.quickToggleTyping), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_disable_screenshot), FeatureFlags.quickToggleScreenshot), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_hide_view_once), FeatureFlags.quickToggleViewOnce), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_hide_story_seen), FeatureFlags.quickToggleStory), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_hide_live_seen), FeatureFlags.quickToggleLive), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_keep_ephemeral), FeatureFlags.quickToggleEphemeral), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_unlimited_replays), FeatureFlags.quickToggleReplays), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_permanent_view), FeatureFlags.quickTogglePermanentView), + createSwitch(context, I18n.t(context, R.string.ig_dialog_quick_allow_screenshots), FeatureFlags.quickToggleAllowScreenshots)}; // Create Enable/Disable All switch - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch = createSwitch(context, "Enable/Disable All", areAllEnabled(toggleSwitches)); + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(toggleSwitches)); // Master listener enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : toggleSwitches) { + for (ToggleRow s :toggleSwitches) { s.setChecked(isChecked); } }); @@ -175,7 +195,7 @@ private static void showGhostQuickToggleOptions(Context context) { enableAllSwitch.setOnCheckedChangeListener(null); enableAllSwitch.setChecked(areAllEnabled(toggleSwitches)); enableAllSwitch.setOnCheckedChangeListener((buttonView2, isChecked2) -> { - for (Switch s2 : toggleSwitches) { + for (ToggleRow s2 :toggleSwitches) { s2.setChecked(isChecked2); } }); @@ -200,6 +220,18 @@ private static void showGhostQuickToggleOptions(Context context) { case 5: FeatureFlags.quickToggleLive = isChecked; break; + case 6: + FeatureFlags.quickToggleEphemeral = isChecked; + break; + case 7: + FeatureFlags.quickToggleReplays = isChecked; + break; + case 8: + FeatureFlags.quickTogglePermanentView = isChecked; + break; + case 9: + FeatureFlags.quickToggleAllowScreenshots = isChecked; + break; } // Save immediately @@ -219,12 +251,12 @@ private static void showGhostQuickToggleOptions(Context context) { layout.addView(createEnableAllSwitch(context, enableAllSwitch)); // Styled enable all switch layout.addView(createDivider(context)); // Divider below - for (Switch s : toggleSwitches) { + for (ToggleRow s :toggleSwitches) { layout.addView(s); } // Show dialog - showSectionDialog(context, "Customize Quick Toggle 🛠️", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_quick_toggle), layout, () -> { }); } @@ -248,21 +280,20 @@ private static View createDivider(Context context) { private static void restartApp(Context context) { try { String packageName = context.getPackageName(); - Intent intent = context.getPackageManager().getLaunchIntentForPackage(packageName); + Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); - if (intent != null) { - clearAppCache(context); // Clear cache first - intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(intent); - // Forcibly kill the current process to ensure a clean restart + if (launchIntent != null) { + clearAppCache(context); + launchIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(launchIntent); Runtime.getRuntime().exit(0); } else { - Toast.makeText(context, "Could not find the app to restart.", Toast.LENGTH_SHORT).show(); + Toast.makeText(context, I18n.t(context, R.string.ig_dialog_restart_not_found), Toast.LENGTH_SHORT).show(); } } catch (Exception e) { String packageName = context.getPackageName(); XposedBridge.log("InstaEclipse: Restart failed for " + packageName + " - " + e.getMessage()); - Toast.makeText(context, "Restart failed: " + e.getMessage(), Toast.LENGTH_LONG).show(); + Toast.makeText(context, I18n.t(context, R.string.ig_dialog_restart_failed, e.getMessage()), Toast.LENGTH_LONG).show(); } } @@ -311,7 +342,7 @@ private static void showDevOptions(Context context) { LinearLayout layout = createSwitchLayout(context); // Developer Mode Switch - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch devModeSwitch = createSwitch(context, "Enable Developer Mode", FeatureFlags.isDevEnabled); + ToggleRow devModeSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_dev_enable), FeatureFlags.isDevEnabled); devModeSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { FeatureFlags.isDevEnabled = isChecked; SettingsManager.saveAllFlags(); @@ -320,75 +351,85 @@ private static void showDevOptions(Context context) { layout.addView(devModeSwitch); layout.addView(createDivider(context)); - // 📥 Import Dev Config Button - Button importButton = new Button(context); - importButton.setText("📥 Import Dev Config"); - importButton.setOnClickListener(v -> { + layout.addView(createActionRow(context, "📥", I18n.t(context, R.string.ig_dialog_dev_import), "#30D158", v -> { Activity instagramActivity = UIHookManager.getCurrentActivity(); if (instagramActivity != null && !instagramActivity.isFinishing()) { - FeatureFlags.isImportingConfig = true; - Intent importIntent = new Intent(); importIntent.setComponent(new ComponentName("ps.reso.instaeclipse", "ps.reso.instaeclipse.mods.devops.config.JsonImportActivity")); importIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - + importIntent.putExtra("target_package", context.getPackageName()); try { instagramActivity.startActivity(importIntent); } catch (Exception e) { XposedBridge.log("InstaEclipse | ❌ Failed to start JsonImportActivity: " + e.getMessage()); - showSimpleDialog(context, "Error", "Unable to open InstaEclipse UI."); + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_unable_open_ui)); } - } else { - showSimpleDialog(context, "Error", "Instagram is not open or ready."); + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_instagram_not_ready)); } - }); + })); - layout.addView(importButton); - - - // 📤 Export Dev Config Button - Button exportButton = new Button(context); - exportButton.setText("📤 Export Dev Config"); - exportButton.setOnClickListener(v -> { - FeatureFlags.isExportingConfig = true; + layout.addView(createActionRow(context, "📤", I18n.t(context, R.string.ig_dialog_dev_export), "#0A84FF", v -> { Activity instagramActivity = UIHookManager.getCurrentActivity(); if (instagramActivity != null && !instagramActivity.isFinishing()) { - ConfigManager.exportCurrentDevConfig(instagramActivity); - - // Launch InstaEclipse export screen - Intent exportIntent = new Intent(); - exportIntent.setComponent(new ComponentName("ps.reso.instaeclipse", "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); - exportIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - try { + File source = new File(context.getFilesDir(), "mobileconfig/mc_overrides.json"); + if (!source.exists()) { + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_mc_overrides_not_found)); + return; + } + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader(new FileReader(source))) { + String line; + while ((line = reader.readLine()) != null) sb.append(line).append("\n"); + } + String json = sb.toString().trim(); + Intent exportIntent = new Intent(); + exportIntent.setComponent(new ComponentName("ps.reso.instaeclipse", "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); + exportIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + exportIntent.putExtra("json_content", json); instagramActivity.startActivity(exportIntent); } catch (Exception e) { - showSimpleDialog(context, "Error", "Unable to open InstaEclipse UI."); + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_failed_read_config, e.getMessage())); } - } else { - showSimpleDialog(context, "Error", "Instagram is not open or ready."); + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_instagram_not_ready)); } - }); + })); + layout.addView(createDivider(context)); - layout.addView(exportButton); + ToggleRow buildExpiredSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_dev_remove_build_expired), FeatureFlags.removeBuildExpiredPopup); + buildExpiredSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + FeatureFlags.removeBuildExpiredPopup = isChecked; + SettingsManager.saveAllFlags(); + }); + layout.addView(buildExpiredSwitch); // Save current dev mode flag when dialog is closed - showSectionDialog(context, "Developer Options 🎛", layout, SettingsManager::saveAllFlags); + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_dev_options), layout, SettingsManager::saveAllFlags); } private static void showGhostOptions(Context context) { LinearLayout layout = createSwitchLayout(context); - Switch[] switches = new Switch[]{createSwitch(context, "Hide Seen", FeatureFlags.isGhostSeen), createSwitch(context, "Hide Typing", FeatureFlags.isGhostTyping), createSwitch(context, "Disable Screenshot Detection", FeatureFlags.isGhostScreenshot), createSwitch(context, "Hide View Once", FeatureFlags.isGhostViewOnce), createSwitch(context, "Hide Story Seen", FeatureFlags.isGhostStory), createSwitch(context, "Hide Live Seen", FeatureFlags.isGhostLive)}; + ToggleRow[] switches = new ToggleRow[]{ + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_hide_dm_seen), FeatureFlags.isGhostSeen), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_hide_typing), FeatureFlags.isGhostTyping), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_hide_story_views), FeatureFlags.isGhostStory), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_hide_live_presence), FeatureFlags.isGhostLive), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_allow_screenshots_dms),FeatureFlags.allowScreenshots), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_bypass_screenshot), FeatureFlags.isGhostScreenshot), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_hide_view_once), FeatureFlags.isGhostViewOnce), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_unlimited_replays), FeatureFlags.enableUnlimitedReplays), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_permanent_view_once), FeatureFlags.permanentViewMode), + createSwitch(context, I18n.t(context, R.string.ig_dialog_ghost_keep_disappearing), FeatureFlags.keepEphemeralMessages)}; - layout.addView(createClickableSection(context, "🛠 Customize Quick Toggle", () -> showGhostQuickToggleOptions(context))); + layout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_customize_quick_toggle), () -> showGhostQuickToggleOptions(context))); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch = createSwitch(context, "Enable/Disable All", areAllEnabled(switches)); + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(switches)); enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : switches) { + for (ToggleRow s :switches) { s.setChecked(isChecked); } }); @@ -399,7 +440,7 @@ private static void showGhostOptions(Context context) { enableAllSwitch.setOnCheckedChangeListener(null); enableAllSwitch.setChecked(areAllEnabled(switches)); enableAllSwitch.setOnCheckedChangeListener((buttonView2, isChecked2) -> { - for (Switch s2 : switches) { + for (ToggleRow s2 :switches) { s2.setChecked(isChecked2); } }); @@ -413,16 +454,28 @@ private static void showGhostOptions(Context context) { FeatureFlags.isGhostTyping = isChecked; break; case 2: - FeatureFlags.isGhostScreenshot = isChecked; + FeatureFlags.isGhostStory = isChecked; break; case 3: - FeatureFlags.isGhostViewOnce = isChecked; + FeatureFlags.isGhostLive = isChecked; break; case 4: - FeatureFlags.isGhostStory = isChecked; + FeatureFlags.allowScreenshots = isChecked; break; case 5: - FeatureFlags.isGhostLive = isChecked; + FeatureFlags.isGhostScreenshot = isChecked; + break; + case 6: + FeatureFlags.isGhostViewOnce = isChecked; + break; + case 7: + FeatureFlags.enableUnlimitedReplays = isChecked; + break; + case 8: + FeatureFlags.permanentViewMode = isChecked; + break; + case 9: + FeatureFlags.keepEphemeralMessages = isChecked; break; } @@ -441,11 +494,11 @@ private static void showGhostOptions(Context context) { layout.addView(createEnableAllSwitch(context, enableAllSwitch)); layout.addView(createDivider(context)); - for (Switch s : switches) { + for (ToggleRow s :switches) { layout.addView(s); } - showSectionDialog(context, "Ghost Mode 👻", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_ghost_mode), layout, () -> { // No need to set FeatureFlags here anymore because handled instantly }); } @@ -455,20 +508,20 @@ private static void showAdOptions(Context context) { LinearLayout layout = createSwitchLayout(context); // Create switches - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch adBlock = createSwitch(context, "Block Ads", FeatureFlags.isAdBlockEnabled); + ToggleRow adBlock = createSwitch(context, I18n.t(context, R.string.ig_dialog_ad_block_ads), FeatureFlags.isAdBlockEnabled); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch analytics = createSwitch(context, "Block Analytics", FeatureFlags.isAnalyticsBlocked); + ToggleRow analytics = createSwitch(context, I18n.t(context, R.string.ig_dialog_ad_block_analytics), FeatureFlags.isAnalyticsBlocked); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch trackingLinks = createSwitch(context, "Disable Tracking Links", FeatureFlags.disableTrackingLinks); + ToggleRow trackingLinks = createSwitch(context, I18n.t(context, R.string.ig_dialog_ad_disable_tracking), FeatureFlags.disableTrackingLinks); - Switch[] switches = new Switch[]{adBlock, analytics, trackingLinks}; + ToggleRow[] switches = new ToggleRow[]{adBlock, analytics, trackingLinks}; // Create Enable/Disable All switch - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch = createSwitch(context, "Enable/Disable All", areAllEnabled(switches)); + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(switches)); // Master listener enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : switches) { + for (ToggleRow s :switches) { s.setChecked(isChecked); } }); @@ -480,7 +533,7 @@ private static void showAdOptions(Context context) { enableAllSwitch.setOnCheckedChangeListener(null); enableAllSwitch.setChecked(areAllEnabled(switches)); enableAllSwitch.setOnCheckedChangeListener((buttonView2, isChecked2) -> { - for (Switch s2 : switches) { + for (ToggleRow s2 :switches) { s2.setChecked(isChecked2); } }); @@ -501,12 +554,12 @@ private static void showAdOptions(Context context) { layout.addView(createEnableAllSwitch(context, enableAllSwitch)); layout.addView(createDivider(context)); - for (Switch s : switches) { + for (ToggleRow s :switches) { layout.addView(s); } // Show the dialog - showSectionDialog(context, "Ad/Analytics Block 🛡️", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_ad_analytics), layout, () -> { }); } @@ -515,19 +568,19 @@ private static void showDistractionOptions(Context context) { LinearLayout layout = createSwitchLayout(context); // Child switches - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch extremeModeSwitch = createSwitch(context, "Extreme Mode 🔒 (Irreversible until reinstall)", FeatureFlags.isExtremeMode); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableStoriesSwitch = createSwitch(context, "Disable Stories", FeatureFlags.disableStories); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableFeedSwitch = createSwitch(context, "Disable Feed", FeatureFlags.disableFeed); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableReelsSwitch = createSwitch(context, "Disable Reels", FeatureFlags.disableReels); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch onlyInDMSwitch = createSwitch(context, "Disable Reels Except in DMs", FeatureFlags.disableReelsExceptDM); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableExploreSwitch = createSwitch(context, "Disable Explore", FeatureFlags.disableExplore); - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableCommentsSwitch = createSwitch(context, "Disable Comments", FeatureFlags.disableComments); + ToggleRow extremeModeSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_extreme_mode), FeatureFlags.isExtremeMode); + ToggleRow disableStoriesSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_stories), FeatureFlags.disableStories); + ToggleRow disableFeedSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_feed), FeatureFlags.disableFeed); + ToggleRow disableReelsSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_reels), FeatureFlags.disableReels); + ToggleRow onlyInDMSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_reels_except_dm), FeatureFlags.disableReelsExceptDM); + ToggleRow disableExploreSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_explore), FeatureFlags.disableExplore); + ToggleRow disableCommentsSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_distraction_disable_comments), FeatureFlags.disableComments); - Switch[] switches = new Switch[]{disableStoriesSwitch, disableFeedSwitch, disableReelsSwitch, onlyInDMSwitch, disableExploreSwitch, disableCommentsSwitch}; + ToggleRow[] switches = new ToggleRow[]{disableStoriesSwitch, disableFeedSwitch, disableReelsSwitch, onlyInDMSwitch, disableExploreSwitch, disableCommentsSwitch}; // Enable/Disable All - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch = createSwitch(context, "Enable/Disable All", areAllEnabled(switches)); + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(switches)); if (FeatureFlags.isExtremeMode) { disableAllSwitches(switches, enableAllSwitch, onlyInDMSwitch); @@ -535,12 +588,26 @@ private static void showDistractionOptions(Context context) { extremeModeSwitch.setEnabled(false); } + // Helper: extreme mode is only available when at least one feature is selected + Runnable updateExtremeSwitchEnabled = () -> { + if (!FeatureFlags.isExtremeMode) { + boolean anyEnabled = false; + for (ToggleRow s : switches) { + if (s.isChecked()) { anyEnabled = true; break; } + } + extremeModeSwitch.setEnabled(anyEnabled); + } + }; + + // Initial state: disable extreme mode toggle if nothing is selected yet + updateExtremeSwitchEnabled.run(); + extremeModeSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setTitle("Activate Extreme Mode?"); - builder.setMessage("Once activated, you cannot disable Distraction-Free Mode until you reinstall the app. Continue?"); - builder.setPositiveButton("Yes", (dialog, which) -> { + builder.setTitle(I18n.t(context, R.string.ig_dialog_distraction_extreme_title)); + builder.setMessage(I18n.t(context, R.string.ig_dialog_distraction_extreme_message)); + builder.setPositiveButton(I18n.t(context, R.string.ig_dialog_yes), (dialog, which) -> { FeatureFlags.isExtremeMode = true; FeatureFlags.isDistractionFree = true; @@ -557,15 +624,14 @@ private static void showDistractionOptions(Context context) { disableAllSwitches(switches, enableAllSwitch, onlyInDMSwitch); extremeModeSwitch.setEnabled(false); }); - builder.setNegativeButton("Cancel", (dialog, which) -> extremeModeSwitch.setChecked(false)); + builder.setNegativeButton(I18n.t(context, R.string.ig_dialog_cancel), (dialog, which) -> extremeModeSwitch.setChecked(false)); builder.show(); } }); - // Master switch listener enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : switches) { + for (ToggleRow s : switches) { s.setChecked(isChecked); s.setEnabled(true); } @@ -573,33 +639,36 @@ private static void showDistractionOptions(Context context) { onlyInDMSwitch.setChecked(false); onlyInDMSwitch.setEnabled(false); } + updateExtremeSwitchEnabled.run(); }); // Parent-child logic for Reels disableReelsSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { onlyInDMSwitch.setEnabled(isChecked); if (!isChecked) { - onlyInDMSwitch.setChecked(false); // turn off child immediately + onlyInDMSwitch.setChecked(false); onlyInDMSwitch.setEnabled(false); } updateMasterSwitch(enableAllSwitch, switches, disableReelsSwitch, onlyInDMSwitch); + updateExtremeSwitchEnabled.run(); SettingsManager.saveAllFlags(); }); // Child logic for "Except in DMs" onlyInDMSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked && !disableReelsSwitch.isChecked()) { - // Auto-enable parent if user enables child disableReelsSwitch.setChecked(true); } updateMasterSwitch(enableAllSwitch, switches, disableReelsSwitch, onlyInDMSwitch); + updateExtremeSwitchEnabled.run(); SettingsManager.saveAllFlags(); }); // All other switches - for (Switch s : new Switch[]{disableStoriesSwitch, disableFeedSwitch, disableExploreSwitch, disableCommentsSwitch}) { + for (ToggleRow s : new ToggleRow[]{disableStoriesSwitch, disableFeedSwitch, disableExploreSwitch, disableCommentsSwitch}) { s.setOnCheckedChangeListener((buttonView, isChecked) -> { updateMasterSwitch(enableAllSwitch, switches, disableReelsSwitch, onlyInDMSwitch); + updateExtremeSwitchEnabled.run(); SettingsManager.saveAllFlags(); }); } @@ -613,11 +682,11 @@ private static void showDistractionOptions(Context context) { layout.addView(createEnableAllSwitch(context, enableAllSwitch)); layout.addView(createDivider(context)); - for (Switch s : switches) { + for (ToggleRow s :switches) { layout.addView(s); } - showSectionDialog(context, "Distraction-Free Instagram 🧘", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_distraction_free), layout, () -> { FeatureFlags.disableStories = disableStoriesSwitch.isChecked(); FeatureFlags.disableFeed = disableFeedSwitch.isChecked(); FeatureFlags.disableReels = disableReelsSwitch.isChecked(); @@ -629,28 +698,22 @@ private static void showDistractionOptions(Context context) { SettingsManager.saveAllFlags(); } - private static void disableAllSwitches(Switch[] switches, @SuppressLint("UseSwitchCompatOrMaterialCode") Switch master, @SuppressLint("UseSwitchCompatOrMaterialCode") Switch onlyInDMSwitch) { - - for (Switch s : switches) { + private static void disableAllSwitches(ToggleRow[] switches, ToggleRow master, ToggleRow onlyInDMSwitch) { + for (ToggleRow s : switches) { if (s == onlyInDMSwitch) { - // Special rule for onlyInDM - s.setEnabled(s.isChecked()); // editable only if it was checked + s.setEnabled(s.isChecked()); } else { - // Normal switches: lock if checked, editable if unchecked s.setEnabled(!s.isChecked()); } } - - // Master switch always frozen ON master.setEnabled(false); } - - private static void updateMasterSwitch(@SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch, Switch[] switches, @SuppressLint("UseSwitchCompatOrMaterialCode") Switch disableReelsSwitch, @SuppressLint("UseSwitchCompatOrMaterialCode") Switch onlyInDMSwitch) { - enableAllSwitch.setOnCheckedChangeListener(null); - enableAllSwitch.setChecked(areAllEnabled(switches)); - enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : switches) { + private static void updateMasterSwitch(ToggleRow enableAllRow, ToggleRow[] switches, ToggleRow disableReelsSwitch, ToggleRow onlyInDMSwitch) { + enableAllRow.setOnCheckedChangeListener(null); + enableAllRow.setChecked(areAllEnabled(switches)); + enableAllRow.setOnCheckedChangeListener((buttonView, isChecked) -> { + for (ToggleRow s : switches) { s.setChecked(isChecked); } onlyInDMSwitch.setEnabled(disableReelsSwitch.isChecked()); @@ -662,19 +725,22 @@ private static void showMiscOptions(Context context) { LinearLayout layout = createSwitchLayout(context); // Create all child switches - Switch[] switches = new Switch[]{ - createSwitch(context, "Disable Story Auto-Swipe", FeatureFlags.disableStoryFlipping), - createSwitch(context, "Disable Video Autoplay", FeatureFlags.disableVideoAutoPlay), - createSwitch(context, "Disable Repost", FeatureFlags.disableRepost), - createSwitch(context, "Show Follower Toast", FeatureFlags.showFollowerToast), - createSwitch(context, "Show Feature Toasts", FeatureFlags.showFeatureToasts) + ToggleRow[] switches = new ToggleRow[]{ + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_disable_story_autoswipe), FeatureFlags.disableStoryFlipping), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_disable_video_autoplay), FeatureFlags.disableVideoAutoPlay), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_disable_repost), FeatureFlags.disableRepost), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_show_feature_toasts), FeatureFlags.showFeatureToasts), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_show_follower_toast), FeatureFlags.showFollowerToast), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_view_story_mentions), FeatureFlags.enableStoryMentions), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_disable_discover_people), FeatureFlags.disableDiscoverPeople), + createSwitch(context, I18n.t(context, R.string.ig_dialog_misc_copy_comment), FeatureFlags.enableCopyComment) }; // Create Enable/Disable All switch - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch = createSwitch(context, "Enable/Disable All", areAllEnabled(switches)); + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(switches)); enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { - for (Switch s : switches) { + for (ToggleRow s :switches) { s.setChecked(isChecked); } }); @@ -685,7 +751,7 @@ private static void showMiscOptions(Context context) { enableAllSwitch.setOnCheckedChangeListener(null); enableAllSwitch.setChecked(areAllEnabled(switches)); enableAllSwitch.setOnCheckedChangeListener((buttonView2, isChecked2) -> { - for (Switch s2 : switches) { + for (ToggleRow s2 :switches) { s2.setChecked(isChecked2); } }); @@ -702,10 +768,19 @@ private static void showMiscOptions(Context context) { FeatureFlags.disableRepost = isChecked; break; case 3: - FeatureFlags.showFollowerToast = isChecked; + FeatureFlags.showFeatureToasts = isChecked; break; case 4: - FeatureFlags.showFeatureToasts = isChecked; + FeatureFlags.showFollowerToast = isChecked; + break; + case 5: + FeatureFlags.enableStoryMentions = isChecked; + break; + case 6: + FeatureFlags.disableDiscoverPeople = isChecked; + break; + case 7: + FeatureFlags.enableCopyComment = isChecked; break; } @@ -718,59 +793,193 @@ private static void showMiscOptions(Context context) { layout.addView(createEnableAllSwitch(context, enableAllSwitch)); layout.addView(createDivider(context)); - for (Switch s : switches) { + for (ToggleRow s :switches) { layout.addView(s); } // Show dialog - showSectionDialog(context, "Miscellaneous ⚙️", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_misc), layout, () -> { }); } + private static void showDownloaderOptions(Context context) { + LinearLayout layout = createSwitchLayout(context); + + layout.addView(createClickableSection(context, I18n.t(context, R.string.ig_dialog_downloader_settings), () -> showDownloaderSettings(context))); + + ToggleRow postSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_posts), FeatureFlags.enablePostDownload); + ToggleRow storySwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_stories), FeatureFlags.enableStoryDownload); + ToggleRow reelSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_reels), FeatureFlags.enableReelDownload); + ToggleRow profileSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_profiles), FeatureFlags.enableProfileDownload); + + ToggleRow[] switches = new ToggleRow[]{postSwitch, storySwitch, reelSwitch, profileSwitch}; + + ToggleRow enableAllSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_enable_disable_all), areAllEnabled(switches)); + + enableAllSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + for (ToggleRow s :switches) { + s.setChecked(isChecked); + } + }); + + for (int i = 0; i < switches.length; i++) { + final int index = i; + switches[i].setOnCheckedChangeListener((buttonView, isChecked) -> { + enableAllSwitch.setOnCheckedChangeListener(null); + enableAllSwitch.setChecked(areAllEnabled(switches)); + enableAllSwitch.setOnCheckedChangeListener((buttonView2, isChecked2) -> { + for (ToggleRow s2 :switches) { + s2.setChecked(isChecked2); + } + }); + + if (index == 0) FeatureFlags.enablePostDownload = isChecked; + if (index == 1) FeatureFlags.enableStoryDownload = isChecked; + if (index == 2) FeatureFlags.enableReelDownload = isChecked; + if (index == 3) FeatureFlags.enableProfileDownload = isChecked; + + SettingsManager.saveAllFlags(); + }); + } + + layout.addView(createDivider(context)); + layout.addView(createEnableAllSwitch(context, enableAllSwitch)); + layout.addView(createDivider(context)); + + for (ToggleRow s :switches) { + layout.addView(s); + } + + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_downloader), layout, () -> {}); + } + + private static void showDownloaderSettings(Context context) { + LinearLayout layout = createSwitchLayout(context); + + String folderRaw = FeatureFlags.downloaderCustomPath.isEmpty() + ? android.os.Environment.getExternalStorageDirectory().getAbsolutePath() + + "/Download/InstaEclipse" + : FeatureFlags.downloaderCustomPath; + // Strip everything up to and including the primary storage root ("…/0/") + // so "/storage/emulated/0/Download/InstaEclipse" → "Download/InstaEclipse" + String folderDisplay = folderRaw.replaceFirst("^.*/0/", ""); + layout.addView(createInfoSection(context, + I18n.t(context, R.string.ig_dialog_downloader_folder), folderDisplay)); + + ToggleRow usernameFolderSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_username_subfolder), FeatureFlags.downloaderUsernameFolder); + ToggleRow timestampSwitch = createSwitch(context, I18n.t(context, R.string.ig_dialog_downloader_add_timestamp), FeatureFlags.downloaderAddTimestamp); + + usernameFolderSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + FeatureFlags.downloaderUsernameFolder = isChecked; + SettingsManager.saveAllFlags(); + }); + timestampSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { + FeatureFlags.downloaderAddTimestamp = isChecked; + SettingsManager.saveAllFlags(); + }); + + layout.addView(createDivider(context)); + layout.addView(usernameFolderSwitch); + layout.addView(timestampSwitch); + + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_downloader_settings), layout, () -> {}); + } + + private static Activity unwrapActivity(Context context) { + while (context instanceof android.content.ContextWrapper wrapper) { + if (context instanceof Activity a) return a; + context = wrapper.getBaseContext(); + } + return null; + } + @SuppressLint("SetTextI18n") + private static void showBackupRestoreOptions(Context context) { + LinearLayout layout = createSwitchLayout(context); + + layout.addView(createActionRow(context, "💾", I18n.t(context, R.string.ig_dialog_backup_settings), "#30D158", v -> { + try { + String json = ps.reso.instaeclipse.utils.backup.SettingsBackupManager.toJson(); + Activity instagramActivity = UIHookManager.getCurrentActivity(); + if (instagramActivity != null && !instagramActivity.isFinishing()) { + Intent exportIntent = new Intent(); + exportIntent.setComponent(new ComponentName("ps.reso.instaeclipse", + "ps.reso.instaeclipse.mods.devops.config.JsonExportActivity")); + exportIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + exportIntent.putExtra("json_content", json); + exportIntent.putExtra("file_name", "instaeclipse_settings.json"); + instagramActivity.startActivity(exportIntent); + } + } catch (Exception e) { + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_backup_failed, e.getMessage())); + } + })); + + layout.addView(createActionRow(context, "📂", I18n.t(context, R.string.ig_dialog_restore_settings), "#0A84FF", v -> { + Activity instagramActivity = UIHookManager.getCurrentActivity(); + if (instagramActivity != null && !instagramActivity.isFinishing()) { + Intent importIntent = new Intent(); + importIntent.setComponent(new ComponentName("ps.reso.instaeclipse", + "ps.reso.instaeclipse.mods.devops.config.JsonImportActivity")); + importIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + importIntent.putExtra("target_package", context.getPackageName()); + importIntent.putExtra("broadcast_action", "ps.reso.instaeclipse.ACTION_RESTORE_SETTINGS"); + instagramActivity.startActivity(importIntent); + } else { + showSimpleDialog(context, I18n.t(context, R.string.ig_dialog_error), I18n.t(context, R.string.ig_dialog_instagram_not_ready)); + } + })); + + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_backup_restore), layout, () -> {}); + } + private static void showAboutDialog(Context context) { LinearLayout layout = new LinearLayout(context); layout.setOrientation(LinearLayout.VERTICAL); - layout.setPadding(60, 40, 60, 20); - layout.setGravity(Gravity.CENTER_HORIZONTAL); + layout.setPadding(40, 24, 40, 16); TextView title = new TextView(context); - title.setText("InstaEclipse 🌘"); + title.setText(I18n.t(context, R.string.ig_dialog_title)); title.setTextColor(Color.WHITE); - title.setTextSize(20f); + title.setTextSize(22f); + title.setTypeface(null, Typeface.BOLD); title.setGravity(Gravity.CENTER); - title.setPadding(0, 0, 0, 20); + title.setPadding(0, 0, 0, 8); TextView creator = new TextView(context); - creator.setText("Created by @reso7200"); - creator.setTextColor(Color.LTGRAY); - creator.setTextSize(16f); + creator.setText(I18n.t(context, R.string.ig_dialog_about_created_by)); + creator.setTextColor(Color.parseColor("#8E8E93")); + creator.setTextSize(14f); creator.setGravity(Gravity.CENTER); - creator.setPadding(0, 0, 0, 30); - - Button githubButton = new Button(context); - githubButton.setText("🌐 GitHub Repo"); - githubButton.setTextColor(Color.WHITE); - githubButton.setBackgroundTintList(ColorStateList.valueOf(Color.parseColor("#3F51B5"))); - githubButton.setPadding(40, 20, 40, 20); - - LinearLayout.LayoutParams githubParams = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); - githubParams.gravity = Gravity.CENTER_HORIZONTAL; - githubButton.setLayoutParams(githubParams); + creator.setPadding(0, 0, 0, 32); + layout.addView(title); + layout.addView(creator); + LinearLayout linksRow = new LinearLayout(context); + linksRow.setOrientation(LinearLayout.HORIZONTAL); + linksRow.setGravity(Gravity.CENTER); + + View githubBtn = createActionRow(context, "🌐", I18n.t(context, R.string.ig_dialog_about_github), "#0A84FF", v -> { + Intent i = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/ReSo7200/InstaEclipse")); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(i); + }); + LinearLayout.LayoutParams btnLp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + githubBtn.setLayoutParams(btnLp); - githubButton.setOnClickListener(v -> { - Intent browserIntent = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://github.com/ReSo7200/InstaEclipse")); - browserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(browserIntent); + View tgBtn = createActionRow(context, "✈️", I18n.t(context, R.string.ig_dialog_about_telegram), "#29B6F6", v -> { + Intent i = new Intent(Intent.ACTION_VIEW, android.net.Uri.parse("https://t.me/InstaEclipse")); + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(i); }); + tgBtn.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); - layout.addView(title); - layout.addView(creator); - layout.addView(githubButton); + linksRow.addView(githubBtn); + linksRow.addView(tgBtn); + layout.addView(linksRow); - showSectionDialog(context, "About", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_about), layout, () -> { }); } @@ -782,23 +991,16 @@ private static void showRestartSection(Context context) { layout.setGravity(Gravity.CENTER_HORIZONTAL); TextView message = new TextView(context); - message.setText("⚠️ Clear app cache and restart?"); + message.setText(I18n.t(context, R.string.ig_dialog_restart_message)); message.setTextColor(Color.WHITE); message.setTextSize(18f); message.setGravity(Gravity.CENTER); message.setPadding(0, 0, 0, 30); - Button restartButton = new Button(context); - restartButton.setText("🔁 Restart Now"); - restartButton.setTextColor(Color.WHITE); - restartButton.setPadding(40, 20, 40, 20); - - restartButton.setOnClickListener(v -> restartApp(context)); - layout.addView(message); - layout.addView(restartButton); + layout.addView(createActionRow(context, "🔁", I18n.t(context, R.string.ig_dialog_restart_now), "#FF453A", v -> restartApp(context))); - showSectionDialog(context, "Restart App", layout, () -> { + showSectionDialog(context, I18n.t(context, R.string.ig_dialog_section_restart), layout, () -> { }); } @@ -807,149 +1009,300 @@ private static void showRestartSection(Context context) { @SuppressLint("SetTextI18n") private static void showSectionDialog(Context context, String title, LinearLayout contentLayout, Runnable onSave) { - if (currentDialog != null) currentDialog.dismiss(); + if (currentDialog != null) { try { currentDialog.dismiss(); } catch (Exception ignored) {} currentDialog = null; } - // Wrap in a card-style layout LinearLayout container = new LinearLayout(context); container.setOrientation(LinearLayout.VERTICAL); - container.setPadding(40, 40, 40, 20); + container.setPadding(0, 0, 0, 0); GradientDrawable background = new GradientDrawable(); - background.setColor(Color.parseColor("#262626")); - background.setCornerRadius(32); + background.setColor(Color.parseColor("#1C1C1E")); + background.setCornerRadii(new float[]{40, 40, 40, 40, 0, 0, 0, 0}); container.setBackground(background); - // Title - TextView titleView = new TextView(context); - titleView.setText(title); - titleView.setTextColor(Color.WHITE); - titleView.setTextSize(22); - titleView.setGravity(Gravity.CENTER); - titleView.setPadding(0, 0, 0, 30); - container.addView(titleView); + container.addView(createDragHandle(context)); - container.addView(createDivider(context)); - container.addView(contentLayout); - container.addView(createDivider(context)); + // Header row: back arrow + title + LinearLayout header = new LinearLayout(context); + header.setOrientation(LinearLayout.HORIZONTAL); + header.setPadding(24, 4, 24, 16); + header.setGravity(Gravity.CENTER_VERTICAL); - // Footer button TextView backBtn = new TextView(context); - backBtn.setText("← Back"); - backBtn.setTextColor(Color.WHITE); - backBtn.setTextSize(16); - backBtn.setGravity(Gravity.CENTER); - - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#40FFFFFF"))); - states.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); - backBtn.setBackground(states); - - backBtn.setPadding(0, 30, 0, 10); + backBtn.setText("‹"); + backBtn.setTextColor(Color.parseColor("#0A84FF")); + backBtn.setTextSize(36); + backBtn.setIncludeFontPadding(false); + backBtn.setGravity(Gravity.CENTER_VERTICAL); + backBtn.setPadding(4, 0, 32, 4); + backBtn.setMinWidth(0); + backBtn.setMinimumWidth(0); + StateListDrawable backBtnBg = new StateListDrawable(); + backBtnBg.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#20FFFFFF"))); + backBtnBg.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); + backBtn.setBackground(backBtnBg); + backBtn.setClickable(true); + backBtn.setFocusable(true); backBtn.setOnClickListener(v -> { onSave.run(); SettingsManager.saveAllFlags(); showEclipseOptionsDialog(context); }); - container.addView(backBtn); + TextView titleView = new TextView(context); + titleView.setText(title); + titleView.setTextColor(Color.WHITE); + titleView.setTextSize(18); + titleView.setTypeface(null, Typeface.BOLD); + + header.addView(backBtn); + header.addView(titleView); + container.addView(header); + container.addView(createDivider(context)); + + // Content with horizontal padding + LinearLayout contentWrapper = new LinearLayout(context); + contentWrapper.setOrientation(LinearLayout.VERTICAL); + contentWrapper.setPadding(24, 0, 24, 0); + contentWrapper.addView(contentLayout); + container.addView(contentWrapper); + + container.addView(createDivider(context)); + + // Bottom padding for nav bar + View bottomPad = new View(context); + bottomPad.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 48)); + container.addView(bottomPad); ScrollView scrollView = new ScrollView(context); scrollView.addView(container); - currentDialog = new AlertDialog.Builder(context).setView(scrollView).setCancelable(true).create(); + currentDialog = createBottomSheetDialog(context, scrollView); + currentDialog.show(); + } - Objects.requireNonNull(currentDialog.getWindow()).setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); - currentDialog.show(); + @SuppressLint("UseSwitchCompatOrMaterialCode") + private static class ToggleRow extends LinearLayout { + private final Switch toggle; + + ToggleRow(Context context, String label, boolean checked) { + super(context); + setOrientation(HORIZONTAL); + setPadding(8, 4, 8, 4); + setGravity(Gravity.CENTER_VERTICAL); + setClickable(true); + setFocusable(true); + + StateListDrawable bg = new StateListDrawable(); + bg.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#2C2C2E"))); + bg.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); + setBackground(bg); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextColor(Color.WHITE); + labelView.setTextSize(16); + labelView.setPadding(0, 20, 16, 20); + LayoutParams lp = new LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f); + labelView.setLayoutParams(lp); + + toggle = new Switch(context); + toggle.setChecked(checked); + toggle.setThumbTintList(new ColorStateList( + new int[][]{new int[]{-android.R.attr.state_enabled}, new int[]{android.R.attr.state_checked}, new int[]{}}, + new int[]{Color.parseColor("#555555"), Color.parseColor("#448AFF"), Color.parseColor("#FFFFFF")})); + toggle.setTrackTintList(new ColorStateList( + new int[][]{new int[]{-android.R.attr.state_enabled}, new int[]{android.R.attr.state_checked}, new int[]{}}, + new int[]{Color.parseColor("#777777"), Color.parseColor("#1C4C78"), Color.parseColor("#CFD8DC")})); + toggle.setClickable(false); + toggle.setFocusable(false); + + addView(labelView); + addView(toggle); + setOnClickListener(v -> { if (isEnabled()) toggle.setChecked(!toggle.isChecked()); }); + } + + boolean isChecked() { return toggle.isChecked(); } + void setChecked(boolean checked) { toggle.setChecked(checked); } + + @Override + public void setEnabled(boolean enabled) { + super.setEnabled(enabled); + toggle.setEnabled(enabled); + setAlpha(enabled ? 1f : 0.38f); + } + + void setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener l) { + toggle.setOnCheckedChangeListener(l); + } + + void makeBold() { + ((TextView) getChildAt(0)).setTypeface(null, Typeface.BOLD); + ((TextView) getChildAt(0)).setTextSize(17); + } } + private static ToggleRow createSwitch(Context context, String label, boolean defaultState) { + return new ToggleRow(context, label, defaultState); + } private static LinearLayout createSwitchLayout(Context context) { LinearLayout layout = new LinearLayout(context); layout.setOrientation(LinearLayout.VERTICAL); - layout.setPadding(40, 30, 40, 30); - layout.setDividerDrawable(new ColorDrawable(Color.DKGRAY)); - layout.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE); - layout.setDividerPadding(20); - + layout.setPadding(16, 8, 16, 8); return layout; } - private static Switch createSwitch(Context context, String label, boolean defaultState) { - @SuppressLint("UseSwitchCompatOrMaterialCode") Switch toggle = new Switch(context); - toggle.setText(label); - toggle.setChecked(defaultState); - toggle.setPadding(30, 20, 30, 20); - toggle.setTextColor(Color.WHITE); - toggle.setThumbTintList(createThumbColor()); - toggle.setTrackTintList(createTrackColor()); - toggle.setTextSize(16); - return toggle; - } - - private static ColorStateList createThumbColor() { - return new ColorStateList(new int[][]{new int[]{-android.R.attr.state_enabled}, // Disabled - new int[]{android.R.attr.state_checked}, // Checked - new int[]{-android.R.attr.state_checked} // Unchecked - }, new int[]{Color.parseColor("#555555"), // Disabled - Color.parseColor("#448AFF"), // ON - Color.parseColor("#FFFFFF") // OFF - }); + private static View createClickableSection(Context context, String label, Runnable onClick) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(40, 24, 32, 24); + row.setGravity(Gravity.CENTER_VERTICAL); + + StateListDrawable states = new StateListDrawable(); + states.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#2C2C2E"))); + states.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); + row.setBackground(states); + row.setClickable(true); + row.setFocusable(true); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextSize(17); + labelView.setTextColor(Color.WHITE); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f); + labelView.setLayoutParams(lp); + + TextView chevron = new TextView(context); + chevron.setText("›"); + chevron.setTextSize(22); + chevron.setTextColor(Color.parseColor("#8E8E93")); + chevron.setPadding(8, 0, 0, 0); + + row.addView(labelView); + row.addView(chevron); + row.setOnClickListener(v -> onClick.run()); + return row; } - private static ColorStateList createTrackColor() { - return new ColorStateList(new int[][]{new int[]{-android.R.attr.state_enabled}, // Disabled - new int[]{android.R.attr.state_checked}, // Checked - new int[]{-android.R.attr.state_checked} // Unchecked - }, new int[]{Color.parseColor("#777777"), // Disabled - Color.parseColor("#1C4C78"), // ON - Color.parseColor("#CFD8DC") // OFF - }); + private static View createInfoSection(Context context, String label, String value) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(40, 24, 32, 24); + row.setGravity(Gravity.CENTER_VERTICAL); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextSize(17); + labelView.setTextColor(Color.WHITE); + labelView.setLayoutParams(new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT)); + + TextView valueView = new TextView(context); + valueView.setText(value); + valueView.setTextSize(13); + valueView.setTextColor(Color.parseColor("#8E8E93")); + valueView.setMaxLines(1); + valueView.setEllipsize(android.text.TextUtils.TruncateAt.START); + valueView.setPadding(16, 0, 0, 0); + // weight=1 / width=0: value fills remaining space and truncates at start if too long + valueView.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + + row.addView(labelView); + row.addView(valueView); + return row; } - private static View createClickableSection(Context context, String label, Runnable onClick) { - TextView section = new TextView(context); - section.setText(label); - section.setTextSize(18); - section.setTextColor(Color.WHITE); - section.setPadding(20, 24, 20, 24); + private static View createActionRow(Context context, String emoji, String label, String accentHex, View.OnClickListener onClick) { + LinearLayout row = new LinearLayout(context); + row.setOrientation(LinearLayout.HORIZONTAL); + row.setPadding(40, 22, 32, 22); + row.setGravity(Gravity.CENTER_VERTICAL); + row.setClickable(true); + row.setFocusable(true); + + StateListDrawable bg = new StateListDrawable(); + bg.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#2C2C2E"))); + bg.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); + row.setBackground(bg); + + // Colored icon badge + TextView iconView = new TextView(context); + iconView.setText(emoji); + iconView.setTextSize(18); + GradientDrawable iconBg = new GradientDrawable(); + iconBg.setColor(Color.parseColor(accentHex + "33")); // 20% opacity tint + iconBg.setCornerRadius(14); + iconView.setBackground(iconBg); + iconView.setPadding(14, 10, 14, 10); + LinearLayout.LayoutParams iconLp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT); + iconLp.rightMargin = 24; + iconView.setLayoutParams(iconLp); + + TextView labelView = new TextView(context); + labelView.setText(label); + labelView.setTextSize(16); + labelView.setTextColor(Color.parseColor(accentHex)); + labelView.setLayoutParams(new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)); + + row.addView(iconView); + row.addView(labelView); + row.setOnClickListener(onClick); + return row; + } - StateListDrawable states = new StateListDrawable(); - states.addState(new int[]{android.R.attr.state_pressed}, new ColorDrawable(Color.parseColor("#40FFFFFF"))); - states.addState(new int[]{}, new ColorDrawable(Color.TRANSPARENT)); - section.setBackground(states); + private static View createDragHandle(Context context) { + LinearLayout wrapper = new LinearLayout(context); + wrapper.setOrientation(LinearLayout.VERTICAL); + wrapper.setGravity(Gravity.CENTER_HORIZONTAL); + wrapper.setPadding(0, 14, 0, 8); + + View handle = new View(context); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(120, 6); + lp.gravity = Gravity.CENTER_HORIZONTAL; + handle.setLayoutParams(lp); + GradientDrawable handleBg = new GradientDrawable(); + handleBg.setColor(Color.parseColor("#48484A")); + handleBg.setCornerRadius(3); + handle.setBackground(handleBg); + + wrapper.addView(handle); + return wrapper; + } - section.setOnClickListener(v -> onClick.run()); - return section; + private static AlertDialog createBottomSheetDialog(Context context, View contentView) { + AlertDialog dialog = new AlertDialog.Builder(context).setView(contentView).setCancelable(true).create(); + Window window = dialog.getWindow(); + if (window != null) { + window.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); + window.setGravity(Gravity.BOTTOM); + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + window.getAttributes().windowAnimations = android.R.style.Animation_InputMethod; + } + return dialog; } - private static LinearLayout createEnableAllSwitch(Context context, @SuppressLint("UseSwitchCompatOrMaterialCode") Switch enableAllSwitch) { - // Customize the main Enable/Disable All switch style - enableAllSwitch.setTextSize(18f); - enableAllSwitch.setTextColor(Color.WHITE); - enableAllSwitch.setTypeface(null, Typeface.BOLD); - enableAllSwitch.setPadding(40, 40, 40, 40); + private static LinearLayout createEnableAllSwitch(Context context, ToggleRow enableAllRow) { + enableAllRow.makeBold(); - // Create a container layout LinearLayout container = new LinearLayout(context); container.setOrientation(LinearLayout.VERTICAL); - container.setPadding(20, 20, 20, 20); + container.setPadding(8, 4, 8, 4); - // Background with rounded corners GradientDrawable background = new GradientDrawable(); - background.setColor(Color.parseColor("#333333")); // Dark grey background - background.setCornerRadius(24); + background.setColor(Color.parseColor("#2C2C2E")); + background.setCornerRadius(16); container.setBackground(background); - container.addView(enableAllSwitch); - + container.addView(enableAllRow); return container; } - private static boolean areAllEnabled(Switch[] switches) { - for (Switch s : switches) { - if (!s.isChecked()) return false; + private static boolean areAllEnabled(ToggleRow[] rows) { + for (ToggleRow r : rows) { + if (!r.isChecked()) return false; } return true; } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureFlags.java b/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureFlags.java index 046f8ab7..82c9a044 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureFlags.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureFlags.java @@ -4,8 +4,6 @@ public class FeatureFlags { // Dev Options public static boolean isDevEnabled = false; - public static boolean isImportingConfig = false; - public static boolean isExportingConfig = false; // Ghost Mode public static boolean isGhostModeEnabled = false; @@ -13,8 +11,12 @@ public class FeatureFlags { public static boolean isGhostTyping = false; public static boolean isGhostScreenshot = false; public static boolean isGhostViewOnce = false; + public static boolean enableUnlimitedReplays = false; public static boolean isGhostStory = false; public static boolean isGhostLive = false; + public static boolean allowScreenshots = false; + public static boolean keepEphemeralMessages = false; + public static boolean permanentViewMode = false; // Which ghost mode features the quick toggle will control public static boolean quickToggleSeen = false; @@ -23,6 +25,10 @@ public class FeatureFlags { public static boolean quickToggleViewOnce = false; public static boolean quickToggleStory = false; public static boolean quickToggleLive = false; + public static boolean quickToggleEphemeral = false; + public static boolean quickToggleReplays = false; + public static boolean quickTogglePermanentView = false; + public static boolean quickToggleAllowScreenshots = false; // Distraction Free @@ -49,4 +55,18 @@ public class FeatureFlags { public static boolean disableRepost = false; + public static boolean enableStoryMentions = false; + public static boolean disableDiscoverPeople = false; + public static boolean removeBuildExpiredPopup = false; + public static boolean enableCopyComment = false; + + // Downloader + public static boolean enablePostDownload = false; + public static boolean enableStoryDownload = false; + public static boolean enableReelDownload = false; + public static boolean enableProfileDownload = false; + public static boolean downloaderUsernameFolder = false; + public static boolean downloaderAddTimestamp = false; + public static String downloaderCustomPath = ""; // human-readable display path + public static String downloaderCustomUri = ""; // SAF tree URI string for actual writes } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureManager.java b/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureManager.java index 339f6fc7..0b6ef9a7 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureManager.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/feature/FeatureManager.java @@ -35,6 +35,12 @@ public static void refreshFeatureStatus() { FeatureStatusTracker.setDisabled("GhostViewOnce"); } + if (FeatureFlags.enableUnlimitedReplays) { + FeatureStatusTracker.setEnabled("UnlimitedReplays"); + } else { + FeatureStatusTracker.setDisabled("UnlimitedReplays"); + } + if (FeatureFlags.isGhostStory) { FeatureStatusTracker.setEnabled("GhostStories"); } else { @@ -47,17 +53,77 @@ public static void refreshFeatureStatus() { FeatureStatusTracker.setDisabled("GhostLive"); } - // Miscellaneous - if (FeatureFlags.showFollowerToast) { - FeatureStatusTracker.setEnabled("ShowFollowerToast"); + if (FeatureFlags.allowScreenshots) { + FeatureStatusTracker.setEnabled("AllowScreenshots"); } else { - FeatureStatusTracker.setDisabled("ShowFollowerToast"); + FeatureStatusTracker.setDisabled("AllowScreenshots"); } + if (FeatureFlags.keepEphemeralMessages) { + FeatureStatusTracker.setEnabled("KeepEphemeralMessages"); + } else { + FeatureStatusTracker.setDisabled("KeepEphemeralMessages"); + } + + if (FeatureFlags.permanentViewMode) { + FeatureStatusTracker.setEnabled("PermanentViewMode"); + } else { + FeatureStatusTracker.setDisabled("PermanentViewMode"); + } + + // Miscellaneous if (FeatureFlags.disableTrackingLinks) { FeatureStatusTracker.setEnabled("DisableTrackingLinks"); } else { FeatureStatusTracker.setDisabled("DisableTrackingLinks"); } + + if (FeatureFlags.showFollowerToast) { + FeatureStatusTracker.setEnabled("FollowerToast"); + } else { + FeatureStatusTracker.setDisabled("FollowerToast"); + } + + if (FeatureFlags.enableStoryMentions) { + FeatureStatusTracker.setEnabled("StoryMentions"); + } else { + FeatureStatusTracker.setDisabled("StoryMentions"); + } + + if (FeatureFlags.disableDiscoverPeople) { + FeatureStatusTracker.setEnabled("DisableDiscoverPeople"); + } else { + FeatureStatusTracker.setDisabled("DisableDiscoverPeople"); + } + + if (FeatureFlags.removeBuildExpiredPopup) { + FeatureStatusTracker.setEnabled("RemoveBuildExpiredPopup"); + } else { + FeatureStatusTracker.setDisabled("RemoveBuildExpiredPopup"); + } + + if (FeatureFlags.enablePostDownload) { + FeatureStatusTracker.setEnabled("PostDownload"); + } else { + FeatureStatusTracker.setDisabled("PostDownload"); + } + + if (FeatureFlags.enableStoryDownload) { + FeatureStatusTracker.setEnabled("StoryDownload"); + } else { + FeatureStatusTracker.setDisabled("StoryDownload"); + } + + if (FeatureFlags.enableReelDownload) { + FeatureStatusTracker.setEnabled("ReelDownload"); + } else { + FeatureStatusTracker.setDisabled("ReelDownload"); + } + + if (FeatureFlags.enableProfileDownload) { + FeatureStatusTracker.setEnabled("ProfileDownload"); + } else { + FeatureStatusTracker.setDisabled("ProfileDownload"); + } } } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/ghost/GhostModeUtils.java b/app/src/main/java/ps/reso/instaeclipse/utils/ghost/GhostModeUtils.java index aa4fc6d2..06c33220 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/ghost/GhostModeUtils.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/ghost/GhostModeUtils.java @@ -4,10 +4,12 @@ import android.content.Context; import android.widget.Toast; +import ps.reso.instaeclipse.R; import ps.reso.instaeclipse.mods.ghost.ui.GhostEmojiManager; import ps.reso.instaeclipse.mods.ui.UIHookManager; import ps.reso.instaeclipse.utils.core.SettingsManager; import ps.reso.instaeclipse.utils.feature.FeatureFlags; +import ps.reso.instaeclipse.utils.i18n.I18n; public class GhostModeUtils { public static boolean isGhostModeActive() { @@ -16,7 +18,11 @@ public static boolean isGhostModeActive() { if (FeatureFlags.quickToggleScreenshot && FeatureFlags.isGhostScreenshot) return true; if (FeatureFlags.quickToggleViewOnce && FeatureFlags.isGhostViewOnce) return true; if (FeatureFlags.quickToggleStory && FeatureFlags.isGhostStory) return true; - return FeatureFlags.quickToggleLive && FeatureFlags.isGhostLive; + if (FeatureFlags.quickToggleLive && FeatureFlags.isGhostLive) return true; + if (FeatureFlags.quickToggleEphemeral && FeatureFlags.keepEphemeralMessages) return true; + if (FeatureFlags.quickToggleReplays && FeatureFlags.enableUnlimitedReplays) return true; + if (FeatureFlags.quickTogglePermanentView && FeatureFlags.permanentViewMode) return true; + return FeatureFlags.quickToggleAllowScreenshots && FeatureFlags.allowScreenshots; } @@ -48,13 +54,29 @@ public static void toggleSelectedGhostOptions(Context context) { anySelected = true; if (FeatureFlags.isGhostLive) shouldDisable = true; } + if (FeatureFlags.quickToggleEphemeral) { + anySelected = true; + if (FeatureFlags.keepEphemeralMessages) shouldDisable = true; + } + if (FeatureFlags.quickToggleReplays) { + anySelected = true; + if (FeatureFlags.enableUnlimitedReplays) shouldDisable = true; + } + if (FeatureFlags.quickTogglePermanentView) { + anySelected = true; + if (FeatureFlags.permanentViewMode) shouldDisable = true; + } + if (FeatureFlags.quickToggleAllowScreenshots) { + anySelected = true; + if (FeatureFlags.allowScreenshots) shouldDisable = true; + } if (!anySelected) { Activity activity = UIHookManager.getCurrentActivity(); if (activity != null) { GhostEmojiManager.addGhostEmojiNextToInbox(activity, false); } - Toast.makeText(context, "❗ No Ghost Mode options selected!", Toast.LENGTH_SHORT).show(); + Toast.makeText(context, "❗ " + I18n.t(context, R.string.ig_toast_ghost_no_options), Toast.LENGTH_SHORT).show(); return; // Nothing to do } @@ -66,6 +88,10 @@ public static void toggleSelectedGhostOptions(Context context) { if (FeatureFlags.quickToggleViewOnce) FeatureFlags.isGhostViewOnce = newState; if (FeatureFlags.quickToggleStory) FeatureFlags.isGhostStory = newState; if (FeatureFlags.quickToggleLive) FeatureFlags.isGhostLive = newState; + if (FeatureFlags.quickToggleEphemeral) FeatureFlags.keepEphemeralMessages = newState; + if (FeatureFlags.quickToggleReplays) FeatureFlags.enableUnlimitedReplays = newState; + if (FeatureFlags.quickTogglePermanentView) FeatureFlags.permanentViewMode = newState; + if (FeatureFlags.quickToggleAllowScreenshots) FeatureFlags.allowScreenshots = newState; // Save changes SettingsManager.saveAllFlags(); @@ -78,9 +104,9 @@ public static void toggleSelectedGhostOptions(Context context) { // Toast if (newState) { - Toast.makeText(context, "👻 Ghost Mode Enabled", Toast.LENGTH_SHORT).show(); + Toast.makeText(context, "👻 " + I18n.t(context, R.string.ig_toast_ghost_enabled), Toast.LENGTH_SHORT).show(); } else { - Toast.makeText(context, "❌ Ghost Mode Disabled", Toast.LENGTH_SHORT).show(); + Toast.makeText(context, "❌ " + I18n.t(context, R.string.ig_toast_ghost_disabled), Toast.LENGTH_SHORT).show(); } } } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/i18n/I18n.java b/app/src/main/java/ps/reso/instaeclipse/utils/i18n/I18n.java new file mode 100644 index 00000000..bf5250b8 --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/utils/i18n/I18n.java @@ -0,0 +1,32 @@ +package ps.reso.instaeclipse.utils.i18n; + +import android.content.Context; + +import androidx.annotation.StringRes; + +import ps.reso.instaeclipse.utils.core.CommonUtils; + +/** + * Loads string resources from the InstaEclipse module APK while running inside + * the host app (Instagram) process. + * + * createPackageContext already inherits the device's current locale/configuration, + * so Android's resource system automatically picks the correct values-xx folder. + * No manual locale override needed. + */ +public final class I18n { + + private I18n() {} + + public static String t(Context hostContext, @StringRes int resId, Object... args) { + try { + Context moduleContext = hostContext.createPackageContext( + CommonUtils.MY_PACKAGE_NAME, Context.CONTEXT_IGNORE_SECURITY); + return args.length == 0 + ? moduleContext.getString(resId) + : moduleContext.getString(resId, args); + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/tracker/FollowIndicatorTracker.java b/app/src/main/java/ps/reso/instaeclipse/utils/tracker/FollowIndicatorTracker.java index 51898706..35078a5d 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/tracker/FollowIndicatorTracker.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/tracker/FollowIndicatorTracker.java @@ -1,6 +1,30 @@ package ps.reso.instaeclipse.utils.tracker; +import java.util.concurrent.ConcurrentHashMap; + public class FollowIndicatorTracker { - public static String currentlyViewedUserId = null; + /** ID of the profile currently being checked, set by the network interceptor. */ + public static volatile String currentlyViewedUserId = null; + /** Epoch ms when currentlyViewedUserId was last set. */ + public static volatile long capturedAt = 0; + + /** + * Populated by the Xposed hook; consumed by the network interceptor when the + * hook fires before the network capture (the common case). + */ + public static final ConcurrentHashMap observedResults + = new ConcurrentHashMap<>(); + + public static class ObservedFollowResult { + public final boolean followedBy; + public final String username; // may be null in obfuscated builds + public final long timestamp; + + public ObservedFollowResult(boolean followedBy, String username, long timestamp) { + this.followedBy = followedBy; + this.username = username; + this.timestamp = timestamp; + } + } } diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/users/UserUtils.java b/app/src/main/java/ps/reso/instaeclipse/utils/users/UserUtils.java new file mode 100644 index 00000000..5ce5088e --- /dev/null +++ b/app/src/main/java/ps/reso/instaeclipse/utils/users/UserUtils.java @@ -0,0 +1,42 @@ +package ps.reso.instaeclipse.utils.users; + +import java.lang.reflect.Method; + +public class UserUtils { + + public static Method userUsernameGetter; + + public static String callUsernameGetter(Object user) { + if (user == null) return null; + + // 1. Try the method DexKit resolved + if (userUsernameGetter != null) { + try { + Object r = userUsernameGetter.invoke(user); + if (r instanceof String s && isValidUsername(s)) { + return s; + } + } catch (Throwable ignored) {} + } + + // 2. Fallback: DexKit was wrong. Scan for the real lowercase username method. + for (Method m : user.getClass().getDeclaredMethods()) { + if (m.getParameterCount() != 0 || !m.getReturnType().equals(String.class)) continue; + try { + m.setAccessible(true); + Object r = m.invoke(user); + if (r instanceof String s && isValidUsername(s)) { + userUsernameGetter = m; // Cache the correct method + return s; + } + } catch (Throwable ignored) {} + } + return null; + } + + public static boolean isValidUsername(String s) { + if (s == null || s.isEmpty()) return false; + // Rejects Caps, Spaces, and Arabic. Accepts only valid IG usernames. + return s.matches("^[a-z0-9._]{2,30}$"); + } +} \ No newline at end of file diff --git a/app/src/main/java/ps/reso/instaeclipse/utils/version/VersionCheckUtility.java b/app/src/main/java/ps/reso/instaeclipse/utils/version/VersionCheckUtility.java index 65790179..b6449e2e 100644 --- a/app/src/main/java/ps/reso/instaeclipse/utils/version/VersionCheckUtility.java +++ b/app/src/main/java/ps/reso/instaeclipse/utils/version/VersionCheckUtility.java @@ -15,7 +15,7 @@ public class VersionCheckUtility { - private static final String CURRENT_VERSION = "0.4.5"; // Current version + private static final String CURRENT_VERSION = "0.5.0"; // Current version private static final String VERSION_CHECK_URL = "https://raw.githubusercontent.com/ReSo7200/InstaEclipse/refs/heads/main/version.json"; // JSON URL public static void checkForUpdates(Context context) { diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml new file mode 100644 index 00000000..e9a2ed45 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 00000000..b469721a --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_save.xml b/app/src/main/res/drawable/ic_save.xml new file mode 100644 index 00000000..866a86db --- /dev/null +++ b/app/src/main/res/drawable/ic_save.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_gear.xml b/app/src/main/res/drawable/ic_settings_gear.xml new file mode 100644 index 00000000..00a3ff31 --- /dev/null +++ b/app/src/main/res/drawable/ic_settings_gear.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 5126db5c..22853f9b 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -5,20 +5,20 @@ android:id="@+id/main" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black" - android:fitsSystemWindows="true"> + android:background="?attr/colorSurface"> + android:fitsSystemWindows="true" + app:elevation="0dp" + android:background="@android:color/transparent"> + android:layout_height="?attr/actionBarSize" + android:background="?attr/colorSurface"> + app:tint="?attr/colorPrimary" /> + android:textColor="?attr/colorOnSurface" /> - + - - - - - + app:layout_behavior="@string/appbar_scrolling_view_behavior" + android:paddingBottom="82dp" + android:clipToPadding="false" /> - - - + - \ No newline at end of file + diff --git a/app/src/main/res/layout/contributor_card.xml b/app/src/main/res/layout/contributor_card.xml index aff460b8..afc691dd 100644 --- a/app/src/main/res/layout/contributor_card.xml +++ b/app/src/main/res/layout/contributor_card.xml @@ -1,79 +1,93 @@ - + + app:strokeWidth="0dp"> - + android:orientation="vertical" + android:gravity="center" + android:padding="12dp"> + android:textAppearance="?attr/textAppearanceLabelMedium" + android:textColor="?attr/colorOnSurface" + android:maxLines="2" + android:ellipsize="end" + android:layout_marginBottom="8dp" + tools:text="Abdul Rahman" /> + + + + + + + + + + + + - - + - - + - - - - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_features.xml b/app/src/main/res/layout/fragment_features.xml new file mode 100644 index 00000000..04abdeea --- /dev/null +++ b/app/src/main/res/layout/fragment_features.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_help.xml b/app/src/main/res/layout/fragment_help.xml index d6e8760d..470884c2 100644 --- a/app/src/main/res/layout/fragment_help.xml +++ b/app/src/main/res/layout/fragment_help.xml @@ -5,25 +5,26 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black" + android:background="?attr/colorSurface" android:fillViewport="true" - android:padding="16dp"> + android:scrollbars="none"> + android:gravity="center_horizontal" + android:padding="16dp"> @@ -47,45 +48,44 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/faq_1" - android:textColor="@color/white" - android:textSize="14sp" + android:textAppearance="?attr/textAppearanceBodyMedium" + android:textColor="?attr/colorOnSurfaceVariant" android:layout_marginBottom="8dp" tools:ignore="HardcodedText" /> + android:textAppearance="?attr/textAppearanceBodyMedium" + android:textColor="?attr/colorOnSurfaceVariant" /> @@ -94,10 +94,10 @@ android:id="@+id/module_not_working_card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="16dp" - app:cardCornerRadius="12dp" + android:layout_marginBottom="12dp" + app:cardCornerRadius="16dp" app:cardElevation="0dp" - app:cardBackgroundColor="#262626" + app:cardBackgroundColor="?attr/colorSurfaceContainerLow" app:strokeWidth="0dp"> @@ -121,12 +121,12 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:text="@string/module_not_working_description" - android:textColor="@color/white" - android:textSize="14sp" - android:layout_marginBottom="8dp" /> + android:textAppearance="?attr/textAppearanceBodyMedium" + android:textColor="?attr/colorOnSurfaceVariant" /> + @@ -179,7 +180,8 @@ android:layout_height="40dp" android:layout_gravity="center" android:src="@drawable/ic_telegram_logo" - app:tint="@color/white" + app:tint="@android:color/white" + android:layoutDirection="ltr" android:contentDescription="@string/go_to_telegram" /> diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8851226f..2efeb7d8 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -4,7 +4,7 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@color/black" + android:background="?attr/colorSurface" android:fillViewport="true" android:scrollbars="none"> @@ -20,236 +20,248 @@ android:id="@+id/instagram_status_card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginBottom="16dp" - app:cardCornerRadius="12dp" + android:layout_marginBottom="12dp" + app:cardCornerRadius="16dp" app:cardElevation="0dp" - app:contentPaddingBottom="10dp" - app:contentPaddingTop="8dp" app:strokeWidth="0dp"> + android:padding="16dp"> - + app:tint="?attr/colorOnPrimaryContainer" /> - - + app:layout_constraintTop_toTopOf="parent"> + + + + + + + + - - - + + android:layout_marginBottom="8dp" + android:text="@string/launch_instagram" + app:cornerRadius="12dp" /> - - - - - - - - - - - - + + + + + + android:padding="16dp"> - + android:layout_alignParentStart="true" + android:layout_alignParentTop="true" + android:layout_marginBottom="6dp" + android:text="@string/how_to_use" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:textColor="?attr/colorOnSurface" + android:textStyle="bold" /> - - - + - - - - - - + + + + + + + + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingTop="16dp" + android:paddingEnd="16dp" + android:paddingBottom="12dp"> - + android:layout_marginBottom="12dp" + android:text="@string/contributors" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:textColor="?attr/colorOnSurface" + android:textStyle="bold" /> - + - - + android:orientation="horizontal" + android:paddingEnd="4dp" /> + + + + + + - - + android:orientation="vertical" + android:paddingStart="16dp" + android:paddingTop="16dp" + android:paddingEnd="16dp" + android:paddingBottom="12dp"> - + android:layout_marginBottom="12dp" + android:text="@string/special_thanks" + android:textAppearance="?attr/textAppearanceTitleMedium" + android:textColor="?attr/colorOnSurface" + android:textStyle="bold" /> - + - - - + android:orientation="horizontal" + android:paddingEnd="4dp" /> + + + + diff --git a/app/src/main/res/layout/item_feature.xml b/app/src/main/res/layout/item_feature.xml new file mode 100644 index 00000000..5f9171d3 --- /dev/null +++ b/app/src/main/res/layout/item_feature.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_feature_header.xml b/app/src/main/res/layout/item_feature_header.xml new file mode 100644 index 00000000..9971980a --- /dev/null +++ b/app/src/main/res/layout/item_feature_header.xml @@ -0,0 +1,12 @@ + + diff --git a/app/src/main/res/menu/bottom_navigation_menu.xml b/app/src/main/res/menu/bottom_navigation_menu.xml index 03523dc9..bb6505cf 100644 --- a/app/src/main/res/menu/bottom_navigation_menu.xml +++ b/app/src/main/res/menu/bottom_navigation_menu.xml @@ -4,6 +4,11 @@ android:icon="@drawable/nav_selector_home" android:title="@string/home" /> + + تحميل ملف APK
كيفية الاستخدام هل لا تزال الوحدة معطلة؟ + الروت جرّب استخدام LSPosed من JingMatrix

إذا كنت بدون روت ثبّت LSPatch من JingMatrix ثم اتبع هذا الدليل]]>
+ + + إغلاق + تفعيل/تعطيل الكل + خطأ + موافق + نعم + إلغاء + خيارات المطور 🎛 + إعدادات وضع الشبح 👻 + حظر الإعلانات/التحليلات 🛡 + إنستغرام بلا إلهاء 🧘 + ميزات متنوعة ⚙ + التنزيل 📥 + النسخ الاحتياطي والاستعادة 💾 + حول ℹ️ + إعادة تشغيل التطبيق 🔁 + خيارات المطور 🎛 + وضع الشبح 👻 + حظر الإعلانات/التحليلات 🛡️ + إنستغرام بلا إلهاء 🧘 + متنوع ⚙️ + التنزيل 📥 + إعدادات التنزيل ⚙️ + النسخ الاحتياطي والاستعادة 💾 + حول + إعادة تشغيل التطبيق + تخصيص التبديل السريع 🛠️ + تفعيل وضع المطور + استيراد إعدادات المطور + تصدير إعدادات المطور + إزالة نافذة انتهاء صلاحية الإصدار + تعذر فتح واجهة InstaEclipse. + إنستغرام غير مفتوح أو غير جاهز. + ملف mc_overrides.json غير موجود. + فشل قراءة الإعدادات: %1$s + لم يتم استلام بيانات الإعداد. + تم تصدير الإعداد بنجاح. + فشل الحفظ: %1$s + عنوان URI غير صالح + ✅ تم استيراد الإعداد. + ❌ فشل استيراد الإعداد. + + + اختر ملف JSON + ❌ لم يتم تحديد التطبيق المستهدف. + ✅ تم الإرسال إلى إنستغرام. + ❌ الملف ليس JSON صالحاً. + ❌ فشل قراءة الملف: %1$s + تم الإلغاء أو لم يتم اختيار ملف. + إخفاء رؤية الرسائل المباشرة + إخفاء مؤشر الكتابة + إخفاء مشاهدات القصص + إخفاء الحضور في البث المباشر + السماح بلقطات الشاشة في الرسائل المباشرة + تجاوز كشف لقطات الشاشة + إخفاء فتح الرسائل المرئية لمرة + تشغيل غير محدود للمشاركات المؤقتة + ⚠️ تحويل الوسائط المؤقتة إلى دائمة + الاحتفاظ بالرسائل المختفية + تخصيص التبديل السريع 🛠 + تضمين إخفاء رؤية الرسائل المباشرة + تضمين إخفاء مؤشر الكتابة + تضمين تجاوز كشف لقطات الشاشة + تضمين إخفاء فتح الرسائل المرئية لمرة + تضمين إخفاء مشاهدات القصص + تضمين إخفاء الحضور في البث المباشر + تضمين الاحتفاظ بالرسائل المختفية + تضمين تشغيل غير محدود للمشاركات المؤقتة + تضمين تحويل الوسائط المؤقتة إلى دائمة + تضمين السماح بلقطات الشاشة في الرسائل المباشرة + حظر الإعلانات + حظر التحليلات + تعطيل روابط التتبع + الوضع المتطرف 🔒 (لا رجعة حتى إعادة التثبيت) + تعطيل القصص + تعطيل المحتوى الرئيسي + تعطيل الريلز + تعطيل الريلز إلا في الرسائل المباشرة + تعطيل الاستكشاف + تعطيل التعليقات + تفعيل وضع الاغلاق + بمجرد التفعيل، لن تتمكن من تعطيل وضع بلا إلهاء حتى تعيد تثبيت التطبيق. هل تريد المتابعة؟ + تعطيل التمرير التلقائي للقصص + تعطيل التشغيل التلقائي للفيديو + تعطيل إعادة النشر + عرض إشعارات الميزات + عرض إشعار المتابعة + عرض الإشارات في القصص + تعطيل اكتشاف الأشخاص + نسخ التعليق + + + نسخ التعليق + نسخ التعليق كاملاً + تحديد جزء للنسخ + تحديد للنسخ + نسخ المحدد + ✅ تم النسخ إلى الحافظة! + إعدادات التنزيل ⚙ + تنزيل المنشورات + تنزيل القصص + تنزيل الريلز + تنزيل صور الملف الشخصي + مجلد التنزيل 📁 + الحفظ في مجلد فرعي باسم المستخدم + إضافة الطابع الزمني إلى اسم الملف + لا يمكن فتح منتقي المجلدات هنا + نسخ الإعدادات احتياطياً + استعادة الإعدادات + فشل إنشاء النسخة الاحتياطية: %1$s + GitHub + Telegram + مسح ذاكرة التطبيق وإعادة التشغيل؟ ⚠️ + إعادة التشغيل الآن + تعذر العثور على التطبيق لإعادة التشغيل. + فشلت إعادة التشغيل: %1$s + + + لم يتم تحديد أي خيارات لوضع الشبح! + تم تفعيل وضع الشبح + تم تعطيل وضع الشبح + ✅ تم إرسال الرؤية + ✅ تم إرسال قراءة القناة + + + يتابعك ✅ + لا يتابعك ❌ + + + تم تطبيق الإعدادات! + يرجى إغلاق Instagram يدويًا لتطبيق التغييرات. + تمت استعادة الإعدادات بنجاح. + فشل الاستعادة: %1$s + + + مجلد التنزيل: %1$s + تم تحديث مجلد التنزيل! + تمت إعادة تعيين مجلد التنزيل إلى الافتراضي + + + جارٍ التنزيل… + جارٍ تنزيل الفيديو… + جارٍ تنزيل الصورة… + تم حفظ الفيديو في مجلد InstaEclipse + تم حفظ الصورة في مجلد InstaEclipse + تم الحفظ في مجلد InstaEclipse + فشل التنزيل: %1$s + جارٍ تنزيل الريل… + تم حفظ الريل في مجلد InstaEclipse + فشل تنزيل الريل: %1$s + لم يتم العثور على رابط الريل + لم يتم العثور على رابط المنشور + لم يتم العثور على وسائط لهذا المنشور + لم يتم العثور على وسائط + جارٍ تنزيل فيديو القصة… + جارٍ تنزيل صورة القصة… + تم حفظ القصة في مجلد InstaEclipse + لم يتم العثور على رابط القصة + جارٍ تنزيل صورة الملف الشخصي… + تم حفظ صورة الملف الشخصي! + تعذر الحصول على رابط صورة الملف الشخصي + جارٍ دمج الفيديو والصوت… + جارٍ تنزيل %1$d عنصر… + جارٍ تنزيل جميع %1$d عناصر… + تم حفظ جميع %1$d عناصر في مجلد InstaEclipse + تم حفظ %1$d من %2$d (%3$d فشل) + لم يتم العثور على رابط الريل — قم بالتمرير أولًا + تنزيل + كاروسيل • %1$d عنصر + تنزيل الحالي (%1$d من %2$d) + تنزيل جميع %1$d عناصر + عرض الإشارات + تم نسخ @%1$s + تم نسخ جميع الإشارات + + رجوع + تطبيق التغييرات + + + الفئات: + الأدوات: + الميزات: + الإعدادات: + الخيارات: + التبديل السريع: + المنطقة الخطرة: + مجلد التنزيل: + 📂 تحديد موقع مجلد التنزيل + 📂 المحدد: %1$s + 🗑 إعادة تعيين مجلد التنزيل + + + ⚠️ تم اكتشاف إصدارات متعددة + + + إشارات القصة + %1$d إشارة • انقر للنسخ + لم يتم العثور على إشارات في هذه القصة + نسخ الكل diff --git a/app/src/main/res/values-az/strings.xml b/app/src/main/res/values-az/strings.xml index 9e5cd1b9..3072158e 100644 --- a/app/src/main/res/values-az/strings.xml +++ b/app/src/main/res/values-az/strings.xml @@ -1,123 +1,314 @@ - + InstaEclipse - v0.4.5 Beta - Əsas səhifə - Xüsusiyyətlər + Ana Səhifə + Funksiyalar Kömək - Versiya - Modul aktiv deyil. Xahiş olunur, onu aktivləşdirin. + Versiya + Modul aktiv deyil. Lütfən aktiv edin. - Modul Vəziyyəti: + Modul Statusu: - Modul Vəziyyəti: Qapalı - Zəhmət olmasa, modulu Xposed Installer-da aktivləşdirin. + Modul Statusu: Deaktiv + Lütfən modulu Xposed Installer\'da aktiv edin. - Modul Vəziyyəti: Aktivdir (Root Girişi Yoxdur) - Birbaşa yenidən başlatmaq üçün root girişi tələb olunur. Əl ilə yenidən başlatmaq ehtiyacı var. + Modul Statusu: Aktiv (Root Yoxdur) + Avtomatik yenidən başlatma üçün root lazımdır. Manual yenidən başlatma lazımdır. - Modul Vəziyyəti: Aktivdir + Modul Statusu: Aktiv Modul aktivdir və işləyir. Instagram Yoxlanılır - Instagram quraşdırılıb - Instagram quraşdırılmayıb - Instagram vəziyyətini yoxlayanda xəta - + Quraşdırılmış Instagram Versiyası: + Instagram quraşdırılmayıb. + Instagram statusu yoxlanarkən xəta baş verdi. + Instagram-ı işə sal - APK-nı Yüklə + APK-ni yüklə Necə istifadə etməli - Axtarış simvolun basılı saxlayın - Töhfəçilər + Modul hələ də işləmir? + Root girişiniz varsa JingMatrix\'in LSPosed-dən istifadə etməyi sınayın

Root girişiniz yoxdursa JingMatrix\'in LSPatch-i quraşdırın və sonra bu bələdçini izləyin]]>
+ + Axtarış ikonuna uzun basın + Təhfə Verənlər Xüsusi Təşəkkürlər - - Instagram Logo - instagram_info + - Kömək və Problemlərin Həlli - Yeniləmələri və sənədləri tapın. - GitHub-a keçin - Dəstək Tapın. - Telegram-a Keçin - InstaEclipse Telegram Group - - - InstaEclipse GitHub + Kömək & Problemlərin Həlli + Yeniləmələri və Dökümentasiyanı tapın. + GitHub\'a Keç + Dəstək alın. + Telegram\'a Keç + + GitHub Telegram - Tez-tez verilən suallar - 1. Google Play Versiyasın istifadə edərkən işləmir/xəta olur?\n- APKMirror APK-ların istifadə et - 2. Modul aktiv deyil?\n- LSPosed-də modulu qapadın və təkrar aktivləşdirin. - 3. Xüsusiyyətlər işləmir?\n- Instagram-ı məcburi dayandırın və yenidən başladın. - 4. Tərtibatçı seçimləri xətalara səbəb olur?\n- Tərtibatçı seçimləri rəhbərini izləyin. - 5. Tərtibatçı seçimlərində qəribə etiketlər və ya nömrələr varmı?\n- Stabil versiyalarda qarışıqlıq olduğu üçün Beta və ya Alpha versiyaların istifadə edin. - 6. Diqqəti yayındırmayan işləkdir, ancaq məzmun hələ də görünür?\n- Instagram-ı məcburi dayandırın və keşkəsini təmizləyin. - - - Modul hələ də işləmir? - rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow Tez-tez Soruşulan Suallar
+ 1. Google Play Versiyasındı işləmir və ya xəta verir?\n- APKMirror APK\'lərindən istifadə edin. + 2. Modul aktiv deyil?\n- Modulu LSPosed\'də deaktiv edib yenidən aktiv edin. + 3. Funksiyalar işləmir?\n- Instagram\'ı zorla dayandırıb keşini təmizləyin. + 4. Tərtibatçı variantları xəta verir?\n- Tərtibatçı variantları bələdçisini izləyin. + 5. Tərtibatçı variantlarında qəribə etiketlər və ya nömrələr var?\n- Sabit versiyalardı qarışıq olduğunu gözünə alaraq Beta və ya Alfa versiyalardan istifadə edin. + 6. "Dikkat Dağıtıcıları Bağla" funksiyası aktiv amma məzmun hələ də görünür?\n- Instagram\'ı zorla dayandırın və keşini təmizləyin. - Tərtibatçı Seçimləri - Kabus Rejimi - Diqqəti yayındırmayan - Reklamları Təmizlə - Analitiki Təmizlə + Tərtibatçı Variantları + Gizli Rejim + Diqqət Dağıtıcıları Bağla + Reklamları Sil + Analitikləri Sil - Hamısını Aktiv et/Qapat + Hamısını Aktiv/Deaktiv Et - - Hint + - Kabus Rejimi Seçimləri - Yazılır + Gizli Rejim Seçimləri + Yazır Hekayə - Bir dəfə baxıb - Canlı + Bir dəfə bax + Canlı Yayım Birbaşa Mesajlar - Yazılır - Ekran görüntüsü + "Yazır" Seçimini Deaktiv Et - Tərtibatçı Seçimləri Rəhbəri - Bunu ancaq Beta/Alpha versiyalarında istifadə edin! - Əvvəlcə giriş etdiyinizə əmin olun! + Ekran görüntüsü + Tərtibatçı Variantları Bələdçisi + Yalnız Beta/Alfa versiyalarında istifadə edin! + Əvvəlcə daxil olduğunuzdan əmin olun! - 1. Instagram-ı açın və əsas səhifə düyməsini basıb saxlayın. - 2. Tərtibatçı Seçimləri > MetaConfig Tənzimləmələri & Overrides-a keçin. - 3. \'Employee\' axtarın və aşağıdakı seçimləri aktivləşdirin: - 4. Son olaraq, tətbiq çökməsin önləmək üçün moduldan Tərtibatçı Seçimlərini qapadın + 1. Instagram\'ı açın və Ana Səhifə düyməsini basıb saxlayın. + 2. Tərtibatçı Variantları > MetaConfig Ayarları & Override-lara keçin. + 3. "Employee" sözünü axtarın və aşağıdaki variantları aktiv edin: + 4. Sonda, tətbiqin xəta verməməsi üçün moduldaki Tərtibatçı Variantlarını deaktiv edin. - - • is employee - • is employee or test user - • employee options + - Diqqəti yayındırmayan seçimlər - Hekayələri Gizlət - Səhifələməni qapadın - Reels video Qapadın - Kəṣfi Qapat - Şərhləri Qapadın + Diqqət Dağıtıcıları Bağlat Seçimləri + Hekayələri Deaktiv Et + Axışı Deaktiv Et + Reels\'ləri Deaktiv Et + Kəşf Etməyi Deaktiv Et + Rəyləri Deaktiv Et Müxtəlif Müxtəlif Seçimlər - Hekayə Dəyişməsini Qapat - Video Birbaşa Oynadılmasın Qapat - İzləyici Ani Bildirişi Göstər - Aktivləşən Xüsusiyyətlər Bildirişi Göstər + Hekayə Çevirməni Deaktiv Et + Video Avtomatik Oynatmağı Deaktiv Et - Töhfəçi adı + Aktiv edilmiş funksiyalar + Töhfə Verənin Adı + + + İzləmə statusu + + + Bağla + Hamısını aktivləşdir/deaktivləşdir + Xəta + Tamam + Bəli + Ləğv et + 🎛 Tərtibatçı Seçimləri + 👻 Kölgə Rejimi Ayarları + 🛡 Reklam/Analitika Bloku + 🧘 Diqqəti Dağıtmayan Instagram + ⚙ Müxtəlif Xüsusiyyətlər + 📥 Yükləyici + 💾 Yedəkləmə və Bərpa + ℹ️ Haqqında + 🔁 Tətbiqi Yenidən Başlat + Tərtibatçı Seçimləri 🎛 + Kölgə Rejimi 👻 + Reklam/Analitika Bloku 🛡️ + Diqqəti Dağıtmayan Instagram 🧘 + Müxtəlif ⚙️ + Yükləyici 📥 + Yükləyici Ayarları ⚙️ + Yedəkləmə və Bərpa 💾 + Haqqında + Tətbiqi Yenidən Başlat + Sürətli Keçidi Fərdiləşdir 🛠️ + Tərtibatçı Rejimini Aktiv Et + Tərtibatçı Konfiqurasiyasını İdxal Et + Tərtibatçı Konfiqurasiyasını İxrac Et + Sürüm Vaxtı Keçmiş Bildirişini Sil + InstaEclipse interfeysi açıla bilmir. + Instagram açıq deyil və ya hazır deyil. + mc_overrides.json tapılmadı. + Konfiqurasiya oxuna bilmədi: %1$s + Konfiqurasiya məlumatı alınmadı. + Konfiqurasiya uğurla ixrac edildi. + Saxlanıla bilmədi: %1$s + Yanlış URI + ✅ Konfiqurasiya idxal edildi. + ❌ Konfiqurasiya idxal edilə bilmədi. + + + JSON Konfiqurasiyasını Seç + ❌ Hədəf paket göstərilməyib. + ✅ Instagram\'a göndərildi. + ❌ Etibarlı JSON faylı deyil. + ❌ Fayl oxuna bilmədi: %1$s + Ləğv edildi və ya fayl seçilmədi. + DM Oxundu Bildirişini Gizlət + Yazma Göstərgesini Gizlət + Hekayə Baxışlarını Gizlət + Canlı Yayın Varlığını Gizlət + DM-lərdə Ekran Görüntüsünə İcazə Ver + Ekran Görüntüsü Aşkarlanmasını Atla + Bir Dəfəlik Baxış Açılmasını Gizlət + Limitsiz Oynatma + ⚠️ Müvəqqəti Medianı Daimi Et + Yox Olan Mesajları Saxla + 🛠 Sürətli Keçidi Fərdiləşdir + DM Oxundu Bildirişini Gizlətməni Daxil Et + Yazma Göstərgesini Gizlətməni Daxil Et + Ekran Görüntüsü Aşkarlanmasını Atlamağı Daxil Et + Bir Dəfəlik Baxış Açılmasını Gizlətməni Daxil Et + Hekayə Baxışlarını Gizlətməni Daxil Et + Canlı Yayın Varlığını Gizlətməni Daxil Et + Yox Olan Mesajları Saxlamağı Daxil Et + Limitsiz Oynatmanı Daxil Et + Müvəqqəti Medianı Daimi Etməyi Daxil Et + DM-lərdə Ekran Görüntüsünə İcazə Verməyi Daxil Et + Reklamları Blokla + Analitikanı Blokla + İzləmə Bağlantılarını Deaktiv Et + Ekstremal Rejim 🔒 (Yenidən quraşdıranadək geri alınmaz) + Hekayələri Deaktiv Et + Lenti Deaktiv Et + Reels\'i Deaktiv Et + DM-lər istisna olmaqla Reels\'i Deaktiv Et + Kəşfi Deaktiv Et + Şərhləri Deaktiv Et + Ekstremal Rejimi Aktiv Et? + Aktiv edildikdən sonra tətbiqi yenidən quraşdıranadək Diqqəti Dağıtmayan Rejimi deaktiv edə bilməzsiniz. Davam edilsin? + Hekayə Avtomatik Keçişini Deaktiv Et + Video Avtomatik Oynatmanı Deaktiv Et + Yenidən Paylaşımı Deaktiv Et + Xüsusiyyət Bildirişlərini Göstər + İzləyici Bildirişini Göstər + Hekayə Qeydlərini Gör + Tanış Tapma Xüsusiyyətini Deaktiv Et + Şərhi Kopyala + + + Şərhi Kopyala + Tam Şərhi Kopyala + Kopyalamaq üçün Hissə Seç + Kopyalamaq üçün Seç + Seçilmişi Kopyala + ✅ Mübadilə buferinə kopyalandı! + ⚙ Yükləyici Ayarları + Paylaşımları Yüklə + Hekayələri Yüklə + Reels\'i Yüklə + Profil Şəkillərini Yüklə + 📁 Yükləmə Qovluğu + İstifadəçi adı alt qovluğuna saxla + Fayl adına vaxt damgası əlavə et + Burada qovluq seçici açıla bilmir + Parametrləri Yedəklə + Parametrləri Bərpa Et + Yedəkləmə yaradıla bilmədi: %1$s + GitHub + Telegram + ⚠️ Tətbiqin keşini təmizlə və yenidən başlat? + İndi Yenidən Başlat + Yenidən başlatmaq üçün tətbiq tapılmadı. + Yenidən başlatma uğursuz oldu: %1$s + + + Xəyal rejimi seçimi edilmədi! + Xəyal rejimi aktivləşdirildi + Xəyal rejimi deaktivləşdirildi + ✅ Oxundu göndərildi + ✅ Kanal oxundu göndərildi + + + sizi izləyir ✅ + sizi izləmir ❌ + + + Parametrlər tətbiq edildi! + Dəyişikliklərin qüvvəyə minməsi üçün Instagram-ı əl ilə bağlayın. + Parametrlər uğurla bərpa edildi. + Bərpa uğursuz oldu: %1$s + + + Yükləmə qovluğu: %1$s + Yükləmə qovluğu yeniləndi! + Yükləmə qovluğu standarta sıfırlandı + + + Yüklənir… + Video yüklənir… + Şəkil yüklənir… + Video InstaEclipse qovluğuna saxlanıldı + Şəkil InstaEclipse qovluğuna saxlanıldı + InstaEclipse qovluğuna saxlanıldı + Yükləmə uğursuz oldu: %1$s + Reel yüklənir… + Reel InstaEclipse qovluğuna saxlanıldı + Reel yükləmə uğursuz oldu: %1$s + Reel URL tapılmadı + Paylaşım URL tapılmadı + Bu paylaşım üçün media tapılmadı + Media tapılmadı + Hekayə videosu yüklənir… + Hekayə şəkli yüklənir… + Hekayə InstaEclipse qovluğuna saxlanıldı + Hekayə URL tapılmadı + Profil şəkli yüklənir… + Profil şəkli saxlanıldı! + Profil şəklinin URL-i alına bilmədi + Video və səs birləşdirilir… + %1$d element yüklənir… + Bütün %1$d element yüklənir… + Bütün %1$d element InstaEclipse qovluğuna saxlanıldı + %2$d-dən %1$d element saxlanıldı (%3$d uğursuz) + Reel URL tapılmadı — əvvəlcə reeli sürüşdürün + Yüklə + Karusel • %1$d element + Cari olanı yüklə (%1$d/%2$d) + Bütün %1$d elementi yüklə + Qeydlərə bax + @%1$s kopyalandı + Bütün qeydlər kopyalandı + + Geri + Dəyişiklikləri Tətbiq Et + + + Kateqoriyalar: + Alətlər: + Xüsusiyyətlər: + Konfiqurasiya: + Seçimlər: + Sürətli Keçid: + Təhlükəli Zona: + Yükləmə Qovluğu: + 📂 Yükləmə Qovluğunun Yerini Təyin Et + 📂 Seçilmiş: %1$s + 🗑 Yükləmə Qovluğunu Sıfırla + + + ⚠️ Birdən çox versiya aşkarlandı + + Hekayə Qeydləri + %1$d qeyd • kopyalamaq üçün tıkla + Bu hekayədə qeyd tapılmadı + Hamısını Kopyala
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 61b4046c..afdbd158 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -58,7 +58,7 @@ Modul funktioniert immer noch nicht? - rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow this guide]]>
+ Root-Zugang hast, versuche JingMatrix\'s LSPosed zu verwenden

Wenn du keinen Root-Zugang hast, installiere JingMatrix\'s LSPatch und folge dann dieser Anleitung]]>
Entwickleroptionen @@ -117,4 +117,204 @@ Name des Mitwirkenden + + Schließen + Alle aktivieren/deaktivieren + Fehler + OK + Ja + Abbrechen + 🎛 Entwickleroptionen + 👻 Geistermodus-Einstellungen + 🛡 Werbung/Analytics blockieren + 🧘 Ablenkungsfreies Instagram + ⚙ Weitere Funktionen + 📥 Downloader + 💾 Sichern & Wiederherstellen + ℹ️ Über + 🔁 App neu starten + Entwickleroptionen 🎛 + Geistermodus 👻 + Werbung/Analytics blockieren 🛡️ + Ablenkungsfreies Instagram 🧘 + Sonstiges ⚙️ + Downloader 📥 + Downloader-Einstellungen ⚙️ + Sichern & Wiederherstellen 💾 + Über + App neu starten + Schnellschalter anpassen 🛠️ + Entwicklermodus aktivieren + Entwickler-Konfiguration importieren + Entwickler-Konfiguration exportieren + Ablauf-Popup entfernen + InstaEclipse-Oberfläche kann nicht geöffnet werden. + Instagram ist nicht geöffnet oder nicht bereit. + mc_overrides.json nicht gefunden. + Konfiguration konnte nicht gelesen werden: %1$s + Keine Konfigurationsdaten empfangen. + Konfiguration erfolgreich exportiert. + Speichern fehlgeschlagen: %1$s + Ungültige URI + ✅ Konfiguration importiert. + ❌ Import der Konfiguration fehlgeschlagen. + + + JSON-Konfiguration auswählen + ❌ Zielpaket nicht angegeben. + ✅ An Instagram gesendet. + ❌ Keine gültige JSON-Datei. + ❌ Datei konnte nicht gelesen werden: %1$s + Abgebrochen oder keine Datei ausgewählt. + DM-Gelesen-Status verbergen + Tippindikator verbergen + Story-Aufrufe verbergen + Live-Anwesenheit verbergen + Screenshots in DMs erlauben + Screenshot-Erkennung umgehen + „Einmal ansehen" verbergen + Unbegrenzte Wiedergabe + ⚠️ Einmal-Medien dauerhaft speichern + Verschwindende Nachrichten behalten + 🛠 Schnellschalter anpassen + DM-Gelesen-Status verbergen einschließen + Tippindikator verbergen einschließen + Screenshot-Erkennung umgehen einschließen + „Einmal ansehen" verbergen einschließen + Story-Aufrufe verbergen einschließen + Live-Anwesenheit verbergen einschließen + Verschwindende Nachrichten behalten einschließen + Unbegrenzte Wiedergabe einschließen + Einmal-Medien dauerhaft speichern einschließen + Screenshots in DMs erlauben einschließen + Werbung blockieren + Analytics blockieren + Tracking-Links deaktivieren + Extremmodus 🔒 (Nicht rückgängig bis zur Neuinstallation) + Stories deaktivieren + Feed deaktivieren + Reels deaktivieren + Reels außer in DMs deaktivieren + Entdecken deaktivieren + Kommentare deaktivieren + Extremmodus aktivieren? + Nach der Aktivierung kann der ablenkungsfreie Modus nur durch Neuinstallation deaktiviert werden. Fortfahren? + Automatisches Story-Wechseln deaktivieren + Video-Autoplay deaktivieren + Repost deaktivieren + Funktionsbenachrichtigungen anzeigen + Follower-Benachrichtigung anzeigen + Story-Erwähnungen anzeigen + „Menschen entdecken" deaktivieren + Kommentar kopieren + + + Kommentar kopieren + Ganzen Kommentar kopieren + Teil zum Kopieren auswählen + Zum Kopieren auswählen + Auswahl kopieren + ✅ In die Zwischenablage kopiert! + ⚙ Downloader-Einstellungen + Beiträge herunterladen + Stories herunterladen + Reels herunterladen + Profilbilder herunterladen + 📁 Download-Ordner + Im Benutzernamen-Unterordner speichern + Zeitstempel zum Dateinamen hinzufügen + Ordnerauswahl kann hier nicht geöffnet werden + Einstellungen sichern + Einstellungen wiederherstellen + Backup konnte nicht erstellt werden: %1$s + GitHub + Telegram + ⚠️ App-Cache leeren und neu starten? + Jetzt neu starten + Die App zum Neustart konnte nicht gefunden werden. + Neustart fehlgeschlagen: %1$s + + + Keine Geistermodus-Optionen ausgewählt! + Geistermodus aktiviert + Geistermodus deaktiviert + ✅ Gelesen gesendet + ✅ Kanal gelesen gesendet + + + folgt dir ✅ + folgt dir nicht ❌ + + + Einstellungen angewendet! + Bitte beende Instagram manuell, um die Änderungen zu übernehmen. + Einstellungen erfolgreich wiederhergestellt. + Wiederherstellung fehlgeschlagen: %1$s + + + Download-Ordner: %1$s + Download-Ordner aktualisiert! + Download-Ordner zurückgesetzt + + + Wird heruntergeladen… + Video wird heruntergeladen… + Foto wird heruntergeladen… + Video im InstaEclipse-Ordner gespeichert + Foto im InstaEclipse-Ordner gespeichert + Im InstaEclipse-Ordner gespeichert + Download fehlgeschlagen: %1$s + Reel wird heruntergeladen… + Reel im InstaEclipse-Ordner gespeichert + Reel-Download fehlgeschlagen: %1$s + Reel-URL nicht gefunden + Beitrags-URL nicht gefunden + Keine Medien für diesen Beitrag gefunden + Keine Medien gefunden + Story-Video wird heruntergeladen… + Story-Foto wird heruntergeladen… + Story im InstaEclipse-Ordner gespeichert + Story-URL nicht gefunden + Profilbild wird heruntergeladen… + Profilbild gespeichert! + Profilbild-URL konnte nicht abgerufen werden + Video und Audio werden zusammengeführt… + %1$d Elemente werden heruntergeladen… + Alle %1$d Elemente werden heruntergeladen… + Alle %1$d Elemente im InstaEclipse-Ordner gespeichert + %1$d von %2$d Elementen gespeichert (%3$d fehlgeschlagen) + Keine Reel-URL gefunden — erst durch das Reel scrollen + Herunterladen + Karussell • %1$d Elemente + Aktuelles herunterladen (%1$d von %2$d) + Alle %1$d Elemente herunterladen + Erwähnungen anzeigen + @%1$s kopiert + Alle Erwähnungen kopiert + + Zurück + Änderungen übernehmen + + + Kategorien: + Werkzeuge: + Funktionen: + Konfiguration: + Optionen: + Schnellschalter: + Gefahrenzone: + Download-Ordner: + 📂 Download-Ordner festlegen + 📂 Ausgewählt: %1$s + 🗑 Download-Ordner zurücksetzen + + + ⚠️ Mehrere Versionen erkannt + + + Story-Erwähnungen + %1$d Erwähnung(en) • Tippen zum Kopieren + Keine Erwähnungen in dieser Story gefunden + Alle kopieren + diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 47011473..53dc1fe1 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -1,31 +1,29 @@ - + InstaEclipse + v0.5 Beta Αρχική Λειτουργίες Βοήθεια Έκδοση + Πίσω + Εφαρμογή Αλλαγών + ⚠️ Εντοπίστηκαν πολλαπλές εκδόσεις Το Module δεν είναι ενεργό. Παρακαλούμε ενεργοποιήστε το. - Κατάσταση Module: - Κατάσταση Module: Ανενεργό Παρακαλούμε ενεργοποιήστε το Module στο Xposed Installer. - Κατάσταση Module: Ενεργό (Χωρίς Πρόσβαση Root) Η πρόσβαση Root είναι απαραίτητη για την αυτόματη επανεκκίνηση. Απαιτείται χειροκίνητη επανεκκίνηση. - Κατάσταση Module: Ενεργό Το module είναι ενεργό και λειτουργεί. - Γίνεται έλεγχος του Instagram Το Instagram είναι εγκατεστημένο Το Instagram δεν είναι εγκατεστημένο Σφάλμα ελέγχου κατάστασης του Instagram - Άνοιγμα του Instagram Λήψη APK Οδηγίες χρήσης @@ -43,7 +41,7 @@ Μετάβαση στο GitHub Βρείτε υποστήριξη. Μετάβαση στο Telegram - Ομάδα Telegram του InstaEclipse + InstaEclipse Telegram Group InstaEclipse GitHub @@ -57,10 +55,24 @@ 3. Προβλήματα με τις λειτουργίες του InstaEclipse;\n- Κλείστε αναγκαστικά το Instagram και ανοίξτε το ξανά. 4. Οι επιλογές προγραμματιστών προκαλούν προβλήματα;\n- Ακολουθήστε τον οδηγό επιλογών προγραμματιστών. 5. Οι επιλογές προγραμματιστών έχουν περίεργες ετικέτες ή αριθμούς;\n- Εγκαταστήστε τις εκδόσεις Beta ή Alpha, καθώς οι σταθερές εκδόσεις έχουν απόκρυψη ονομάτων/αριθμών. - 6. Η λειτουργία “Αφαίρεση Περισπασμών” είναι ενεργή αλλά εμφανίζεται ακόμα περιεχόμενο;\n- Κλείστε αναγκαστικά το Instagram και κάντε εκκαθάριση της cache του. + 6. Η λειτουργία "Αφαίρεση Περισπασμών" είναι ενεργή αλλά εμφανίζεται ακόμα περιεχόμενο;\n- Κλείστε αναγκαστικά το Instagram και κάντε εκκαθάριση της cache του. Το Module εξακολουθεί να μην λειτουργεί; + rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow this guide]]>
+ + + Κατηγορίες: + Εργαλεία: + Λειτουργίες: + Ρυθμίσεις: + Επιλογές: + Γρήγορη Εναλλαγή: + Επικίνδυνη Ζώνη: + Φάκελος Λήψεων: + 📂 Ορισμός Θέσης Φακέλου Λήψεων + 📂 Επιλέχθηκε: %1$s + 🗑 Επαναφορά Φακέλου Λήψεων Επιλογές Προγραμματιστών @@ -68,7 +80,6 @@ Αφαίρεση Περισπασμών Αφαίρεση Διαφημίσεων Αφαίρεση Αναλυτικών Στοιχείων - Ενεργοποίηση/Απενεργοποίηση Όλων @@ -88,7 +99,6 @@ Οδηγός Επιλογών Προγραμματιστών Χρησιμοποιήστε το σε εκδόσεις Beta/Alpha μόνο! Βεβαιωθείτε πως έχετε συνδεθεί πρώτα! - 1. Ανοίξτε το Instagram και πατήστε παρατεταμένα το κουμπί της αρχικής σελίδας. 2. Μεταβείτε στο Developer Options > MetaConfig Settings & Overrides. 3. Κάντε αναζήτηση για \'Employee\' και ενεργοποιήστε τις ακόλουθες επιλογές: @@ -117,4 +127,220 @@ Όνομα Συνεισφέροντα + + + + + + + InstaEclipse 🌘 + Κλείσιμο + Ενεργοποίηση/Απενεργοποίηση Όλων + Σφάλμα + ΟΚ + Ναι + Ακύρωση + + + 🎛 Επιλογές Προγραμματιστών + 👻 Ρυθμίσεις Αόρατης Λειτουργίας + 🛡 Αποκλεισμός Διαφημίσεων/Αναλυτικών + 🧘 Instagram Χωρίς Περισπασμούς + ⚙ Διάφορες Λειτουργίες + 📥 Λήψη + 💾 Δημιουργία Αντιγράφου & Επαναφορά + ℹ️ Σχετικά + 🔁 Επανεκκίνηση Εφαρμογής + + + Επιλογές Προγραμματιστών 🎛 + Αόρατη Λειτουργία 👻 + Αποκλεισμός Διαφημίσεων/Αναλυτικών 🛡️ + Instagram Χωρίς Περισπασμούς 🧘 + Διάφορα ⚙️ + Λήψη 📥 + Ρυθμίσεις Λήψης ⚙️ + Δημιουργία Αντιγράφου & Επαναφορά 💾 + Σχετικά + Επανεκκίνηση Εφαρμογής + Προσαρμογή Γρήγορης Εναλλαγής 🛠️ + + + Ενεργοποίηση Λειτουργίας Προγραμματιστή + Εισαγωγή Ρυθμίσεων Προγραμματιστή + Εξαγωγή Ρυθμίσεων Προγραμματιστή + Αφαίρεση Αναδυόμενου Παλαιάς Έκδοσης + Αδύνατο άνοιγμα διεπαφής InstaEclipse. + Το Instagram δεν είναι ανοιχτό ή έτοιμο. + Το αρχείο mc_overrides.json δεν βρέθηκε. + Αδύνατη ανάγνωση ρυθμίσεων: %1$s + Δεν ελήφθησαν δεδομένα ρυθμίσεων. + Οι ρυθμίσεις εξήχθησαν επιτυχώς. + Αποτυχία αποθήκευσης: %1$s + Μη έγκυρο URI + ✅ Οι ρυθμίσεις εισήχθησαν. + ❌ Αποτυχία εισαγωγής ρυθμίσεων. + + + Επιλογή αρχείου JSON + ❌ Δεν ορίστηκε πακέτο προορισμού. + ✅ Στάλθηκε στο Instagram. + ❌ Μη έγκυρο αρχείο JSON. + ❌ Αποτυχία ανάγνωσης αρχείου: %1$s + Ακυρώθηκε ή δεν επιλέχθηκε αρχείο. + + + Απόκρυψη Ανάγνωσης DM + Απόκρυψη Δείκτη Πληκτρολόγησης + Απόκρυψη Προβολών Story + Απόκρυψη Παρουσίας σε Live + Επιτροπή Στιγμιοτύπων σε DM + Παράκαμψη Εντοπισμού Στιγμιοτύπων + Απόκρυψη Ανοίγματος Μηνύματος μιας Χρήσης + Απεριόριστες Επαναλήψεις μιας Χρήσης + ⚠️ Μόνιμα Αρχεία μιας Χρήσης + Διατήρηση Εξαφανιζόμενων Μηνυμάτων + 🛠 Προσαρμογή Γρήγορης Εναλλαγής + + + Συμπερίληψη Απόκρυψης Ανάγνωσης DM + Συμπερίληψη Απόκρυψης Δείκτη Πληκτρολόγησης + Συμπερίληψη Παράκαμψης Εντοπισμού Στιγμιοτύπων + Συμπερίληψη Απόκρυψης Ανοίγματος μιας Χρήσης + Συμπερίληψη Απόκρυψης Προβολών Story + Συμπερίληψη Απόκρυψης Παρουσίας σε Live + Συμπερίληψη Διατήρησης Εξαφανιζόμενων Μηνυμάτων + Συμπερίληψη Απεριόριστων Επαναλήψεων μιας Χρήσης + Συμπερίληψη Μόνιμων Αρχείων μιας Χρήσης + Συμπερίληψη Επιτροπής Στιγμιοτύπων σε DM + + + Αποκλεισμός Διαφημίσεων + Αποκλεισμός Αναλυτικών + Απενεργοποίηση Συνδέσμων Παρακολούθησης + + + Ακραία Λειτουργία 🔒 (Μη αναστρέψιμο μέχρι επανεγκατάσταση) + Απενεργοποίηση Ιστοριών + Απενεργοποίηση Ροής + Απενεργοποίηση Reels + Απενεργοποίηση Reels Εκτός DM + Απενεργοποίηση Εξερεύνησης + Απενεργοποίηση Σχολίων + Ενεργοποίηση Ακραίας Λειτουργίας; + Μετά την ενεργοποίηση, δεν μπορείτε να απενεργοποιήσετε τη Λειτουργία Χωρίς Περισπασμούς μέχρι να επανεγκαταστήσετε την εφαρμογή. Συνέχεια; + + + Απενεργοποίηση Αυτόματης Εναλλαγής Story + Απενεργοποίηση Αυτόματης Αναπαραγωγής Βίντεο + Απενεργοποίηση Αναδημοσίευσης + Εμφάνιση Ειδοποιήσεων Λειτουργιών + Εμφάνιση Ειδοποίησης Ακόλουθου + Προβολή Αναφορών Story + Απενεργοποίηση Προτάσεων Χρηστών + Αντιγραφή Σχολίου + + + Αντιγραφή Σχολίου + Αντιγραφή Ολόκληρου Σχολίου + Επιλογή Τμήματος για Αντιγραφή + Επιλογή για Αντιγραφή + Αντιγραφή Επιλεγμένου + ✅ Αντιγράφηκε στο πρόχειρο! + + + ⚙ Ρυθμίσεις Λήψης + Λήψη Δημοσιεύσεων + Λήψη Ιστοριών + Λήψη Reels + Λήψη Φωτογραφιών Προφίλ + 📁 Φάκελος Λήψεων + Αποθήκευση σε Υποφάκελο Χρήστη + Προσθήκη Χρονοσφραγίδας στο Όνομα Αρχείου + Αδύνατο άνοιγμα επιλογέα φακέλου εδώ + + + Δημιουργία Αντιγράφου Ρυθμίσεων + Επαναφορά Ρυθμίσεων + Αποτυχία δημιουργίας αντιγράφου: %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ Εκκαθάριση cache εφαρμογής και επανεκκίνηση; + Επανεκκίνηση Τώρα + Αδύνατη εύρεση εφαρμογής για επανεκκίνηση. + Αποτυχία επανεκκίνησης: %1$s + + + Δεν επιλέχθηκαν επιλογές Αόρατης Λειτουργίας! + Αόρατη Λειτουργία Ενεργοποιήθηκε + Αόρατη Λειτουργία Απενεργοποιήθηκε + ✅ Εστάλη Ανάγνωση + ✅ Εστάλη Ανάγνωση Καναλιού + + + σε ακολουθεί ✅ + δεν σε ακολουθεί ❌ + + + Οι Ρυθμίσεις Εφαρμόστηκαν! + Παρακαλώ κλείστε το Instagram χειροκίνητα για να εφαρμοστούν οι αλλαγές. + Οι ρυθμίσεις επαναφέρθηκαν επιτυχώς. + Αποτυχία επαναφοράς: %1$s + + + Φάκελος λήψεων: %1$s + Ο φάκελος λήψεων ενημερώθηκε! + Ο φάκελος λήψεων επαναφέρθηκε στην προεπιλογή + + + Γίνεται λήψη… + Γίνεται λήψη βίντεο… + Γίνεται λήψη φωτογραφίας… + Το βίντεο αποθηκεύτηκε στον φάκελο InstaEclipse + Η φωτογραφία αποθηκεύτηκε στον φάκελο InstaEclipse + Αποθηκεύτηκε στον φάκελο InstaEclipse + Αποτυχία λήψης: %1$s + Γίνεται λήψη reel… + Το reel αποθηκεύτηκε στον φάκελο InstaEclipse + Αποτυχία λήψης reel: %1$s + Η διεύθυνση URL του reel δεν βρέθηκε + Η διεύθυνση URL της δημοσίευσης δεν βρέθηκε + Δεν βρέθηκε πολυμέσο για αυτή τη δημοσίευση + Δεν βρέθηκε πολυμέσο + Γίνεται λήψη βίντεο ιστορίας… + Γίνεται λήψη φωτογραφίας ιστορίας… + Η ιστορία αποθηκεύτηκε στον φάκελο InstaEclipse + Η διεύθυνση URL της ιστορίας δεν βρέθηκε + Γίνεται λήψη εικόνας προφίλ… + Η εικόνα προφίλ αποθηκεύτηκε! + Αδύνατη λήψη URL εικόνας προφίλ + Συγχώνευση βίντεο + ήχου… + Γίνεται λήψη %1$d αρχείων… + Γίνεται λήψη όλων των %1$d αρχείων… + Όλα τα %1$d αρχεία αποθηκεύτηκαν στον φάκελο InstaEclipse + %1$d από %2$d αρχεία αποθηκεύτηκαν (%3$d απέτυχαν) + Η διεύθυνση URL του reel δεν βρέθηκε — κάντε κύλιση στο reel πρώτα + + + Λήψη + Καρουζέλ • %1$d αρχεία + Λήψη τρέχοντος (%1$d από %2$d) + Λήψη όλων των %1$d αρχείων + + + Προβολή Αναφορών + @%1$s αντιγράφηκε + Όλες οι αναφορές αντιγράφηκαν + + + Αναφορές Story + %1$d αναφορά(ές) • πατήστε για αντιγραφή + Δεν βρέθηκαν αναφορές σε αυτή την ιστορία + Αντιγραφή Όλων +
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 611a9349..1672148a 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -29,7 +29,8 @@ Abrir Instagram Descargar APK Cómo usar - Modül hâlâ çalışmıyor mu? + ¿El módulo todavía no funciona? + root, prueba usando LSPosed de JingMatrix

Si no tienes root, instala LSPatch de JingMatrix y luego sigue esta guía]]>
Mantén presionado el ícono de búsqueda Contribuidores @@ -112,4 +113,204 @@ Estado de seguimiento + + + Cerrar + Activar/Desactivar todo + Error + Aceptar + + Cancelar + 🎛 Opciones de desarrollador + 👻 Configuración del modo fantasma + 🛡 Bloqueo de anuncios/analíticas + 🧘 Instagram sin distracciones + ⚙ Funciones adicionales + 📥 Descargador + 💾 Copia de seguridad y restauración + ℹ️ Acerca de + 🔁 Reiniciar aplicación + Opciones de desarrollador 🎛 + Modo fantasma 👻 + Bloqueo de anuncios/analíticas 🛡️ + Instagram sin distracciones 🧘 + Miscelánea ⚙️ + Descargador 📥 + Configuración del descargador ⚙️ + Copia de seguridad y restauración 💾 + Acerca de + Reiniciar aplicación + Personalizar acceso rápido 🛠️ + Activar modo desarrollador + Importar configuración de desarrollador + Exportar configuración de desarrollador + Eliminar ventana de compilación caducada + No se puede abrir la interfaz de InstaEclipse. + Instagram no está abierto o listo. + mc_overrides.json no encontrado. + Error al leer la configuración: %1$s + No se recibieron datos de configuración. + Configuración exportada correctamente. + Error al guardar: %1$s + URI no válida + ✅ Configuración importada. + ❌ Error al importar la configuración. + + + Seleccionar config JSON + ❌ Paquete de destino no especificado. + ✅ Enviado a Instagram. + ❌ No es un archivo JSON válido. + ❌ Error al leer el archivo: %1$s + Cancelado o sin archivo seleccionado. + Ocultar lectura en mensajes directos + Ocultar indicador de escritura + Ocultar visualizaciones de historias + Ocultar presencia en directo + Permitir capturas en mensajes directos + Omitir detección de capturas de pantalla + Ocultar apertura de mensajes de una vez + Repeticiones ilimitadas de vista única + ⚠️ Convertir medios temporales en permanentes + Conservar mensajes que desaparecen + 🛠 Personalizar acceso rápido + Incluir ocultar lectura en mensajes directos + Incluir ocultar indicador de escritura + Incluir omitir detección de capturas de pantalla + Incluir ocultar apertura de mensajes de una vez + Incluir ocultar visualizaciones de historias + Incluir ocultar presencia en directo + Incluir conservar mensajes que desaparecen + Incluir repeticiones ilimitadas de vista única + Incluir convertir medios temporales en permanentes + Incluir permitir capturas en mensajes directos + Bloquear anuncios + Bloquear analíticas + Desactivar enlaces de rastreo + Modo extremo 🔒 (Irreversible hasta reinstalar) + Desactivar historias + Desactivar noticias + Desactivar Reels + Desactivar Reels excepto en mensajes directos + Desactivar explorar + Desactivar comentarios + ¿Activar el modo extremo? + Una vez activado, no podrás desactivar el modo sin distracciones hasta reinstalar la app. ¿Continuar? + Desactivar deslizamiento automático de historias + Desactivar reproducción automática de vídeo + Desactivar republicación + Mostrar notificaciones de funciones + Mostrar notificación de seguidor + Ver menciones en historias + Desactivar descubrir personas + Copiar comentario + + + Copiar comentario + Copiar comentario completo + Seleccionar parte para copiar + Seleccionar para copiar + Copiar selección + ✅ ¡Copiado al portapapeles! + ⚙ Configuración del descargador + Descargar publicaciones + Descargar historias + Descargar Reels + Descargar fotos de perfil + 📁 Carpeta de descarga + Guardar en subcarpeta de nombre de usuario + Añadir marca de tiempo al nombre de archivo + No se puede abrir el selector de carpetas aquí + Copia de seguridad de configuración + Restaurar configuración + Error al crear copia de seguridad: %1$s + GitHub + Telegram + ⚠️ ¿Limpiar caché de la app y reiniciar? + Reiniciar ahora + No se pudo encontrar la aplicación para reiniciar. + Error al reiniciar: %1$s + + + ¡No se seleccionaron opciones de Modo Fantasma! + Modo Fantasma activado + Modo Fantasma desactivado + ✅ Visto enviado + ✅ Canal visto enviado + + + te sigue ✅ + no te sigue ❌ + + + ¡Configuración aplicada! + Por favor, cierra Instagram manualmente para que los cambios surtan efecto. + Configuración restaurada correctamente. + Error al restaurar: %1$s + + + Carpeta de descarga: %1$s + ¡Carpeta de descarga actualizada! + Carpeta de descarga restablecida + + + Descargando… + Descargando vídeo… + Descargando foto… + Vídeo guardado en la carpeta InstaEclipse + Foto guardada en la carpeta InstaEclipse + Guardado en la carpeta InstaEclipse + Error al descargar: %1$s + Descargando reel… + Reel guardado en la carpeta InstaEclipse + Error al descargar reel: %1$s + URL del reel no encontrada + URL de la publicación no encontrada + No se encontraron medios para esta publicación + No se encontraron medios + Descargando vídeo de historia… + Descargando foto de historia… + Historia guardada en la carpeta InstaEclipse + URL de la historia no encontrada + Descargando foto de perfil… + ¡Foto de perfil guardada! + No se pudo obtener la URL de la foto de perfil + Fusionando vídeo y audio… + Descargando %1$d elementos… + Descargando todos los %1$d elementos… + Los %1$d elementos guardados en la carpeta InstaEclipse + %1$d de %2$d elementos guardados (%3$d fallidos) + URL del reel no encontrada — desplázate por el reel primero + Descargar + Carrusel • %1$d elementos + Descargar actual (%1$d de %2$d) + Descargar todos los %1$d elementos + Ver menciones + @%1$s copiado + Todas las menciones copiadas + + Atrás + Aplicar cambios + + + Categorías: + Herramientas: + Funciones: + Configuración: + Opciones: + Acceso rápido: + Zona de peligro: + Carpeta de descarga: + 📂 Establecer carpeta de descarga + 📂 Seleccionada: %1$s + 🗑 Restablecer carpeta de descarga + + + ⚠️ Varias versiones detectadas + + + Menciones de historia + %1$d mención(es) • toca para copiar + No se encontraron menciones en esta historia + Copiar todo
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7c54be53..c17f8a5d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -1,31 +1,28 @@ InstaEclipse - v0.4.5 Beta + v0.5 Beta Menu Fonctionnalités Aide Version - Le module n’est pas activé. Veuillez l’activer. - + Retour + Appliquer les modifications + ⚠️ Plusieurs versions détectées + Le module n\'est pas activé. Veuillez l\'activer. Statut du module : - Statut du module : Désactiver Veuillez activer le module dans Xposed. - Statut du module : Activé (Accès Non Rooté) L\'accès Root est requis pour le redémarrage-auto. Redémarrage manuel requis. - Statut du module : Activé Le module est actif et fonctionnel. - Vérification d\'Instagram Instagram est installé Instagram n\'est pas installé Erreur lors de la vérification du statut d\'Instagram - Lancer Instagram Télécharger l\'APK Comment utiliser @@ -63,13 +60,25 @@ Le module ne fonctionne toujours pas? rooté essayez d\'utiliser JingMatrix\'s LSPosed

Si vous êtes non rooté installé JingMatrix\'s LSPatch puis suivez ce guide]]>
+ + Catégories : + Outils : + Fonctionnalités : + Configuration : + Options : + Bascule rapide : + Zone de danger : + Dossier de téléchargement : + 📂 Définir l\'emplacement du dossier de téléchargement + 📂 Sélectionné : %1$s + 🗑 Réinitialiser le dossier de téléchargement + Options Développeurs Mode Fantôme Sans Distractions Enlevé les pubs Désactiver les Analyses - Activé/Désactiver Tous @@ -89,7 +98,6 @@ Guide options développeurs Utiliser le uniquement sur les versions Beta/Alpha seulement ! Assurer vous d\'être d\'abord connecté ! - 1. Ouvrez Instagram et maintenez le bouton Menu. 2. Navigué au options développeurs > MetaConfig Settings & Overrides. 3. Recherchez \'Employee\' et activer les options suivantes: @@ -119,4 +127,219 @@ Contributor Name + + + + + + InstaEclipse 🌘 + Fermer + Activer/Désactiver Tous + Erreur + OK + Oui + Annuler + + + 🎛 Options Développeurs + 👻 Paramètres Mode Fantôme + 🛡 Blocage Pubs/Analytics + 🧘 Instagram Sans Distractions + ⚙ Fonctions Diverses + 📥 Téléchargeur + 💾 Sauvegarde & Restauration + ℹ️ À propos + 🔁 Redémarrer l\'app + + + Options Développeurs 🎛 + Mode Fantôme 👻 + Blocage Pubs/Analytics 🛡️ + Instagram Sans Distractions 🧘 + Divers ⚙️ + Téléchargeur 📥 + Paramètres Téléchargeur ⚙️ + Sauvegarde & Restauration 💾 + À propos + Redémarrer l\'app + Personnaliser Bascule Rapide 🛠️ + + + Activer le Mode Développeur + Importer Config Dev + Exporter Config Dev + Supprimer le Popup Build Expiré + Impossible d\'ouvrir l\'interface InstaEclipse. + Instagram n\'est pas ouvert ou prêt. + mc_overrides.json introuvable. + Échec de lecture de la config : %1$s + Aucune donnée de config reçue. + Config exportée avec succès. + Échec de sauvegarde : %1$s + URI invalide + ✅ Config importée. + ❌ Échec d\'importation de config. + + + Sélectionner un fichier JSON + ❌ Package cible non spécifié. + ✅ Envoyé à Instagram. + ❌ Fichier JSON invalide. + ❌ Échec de lecture du fichier : %1$s + Annulé ou aucun fichier sélectionné. + + + Masquer Lu DM + Masquer Indicateur de frappe + Masquer Vues des Stories + Masquer Présence en Live + Autoriser captures d\'écran en DM + Contourner Détection de capture d\'écran + Masquer Ouverture Vue Unique + Relectures illimitées Vue Unique + ⚠️ Médias Vue Unique permanents + Conserver Messages éphémères + 🛠 Personnaliser Bascule Rapide + + + Inclure Masquer Lu DM + Inclure Masquer Indicateur de frappe + Inclure Contourner Détection de capture d\'écran + Inclure Masquer Ouverture Vue Unique + Inclure Masquer Vues des Stories + Inclure Masquer Présence en Live + Inclure Conserver Messages éphémères + Inclure Relectures illimitées Vue Unique + Inclure Médias Vue Unique permanents + Inclure Autoriser captures d\'écran en DM + + + Bloquer les pubs + Bloquer les analytics + Désactiver liens de suivi + + + Mode Extrême 🔒 (Irréversible jusqu\'à réinstallation) + Désactiver les Stories + Désactiver le fil d\'actualité + Désactiver les Reels + Désactiver les Reels sauf en DM + Désactiver Explorer + Désactiver les Commentaires + Activer le Mode Extrême ? + Une fois activé, vous ne pouvez pas désactiver le Mode Sans Distractions jusqu\'à réinstaller l\'app. Continuer ? + + + Désactiver défilement auto des Stories + Désactiver lecture auto des vidéos + Désactiver Repartage + Afficher Notifications des fonctions + Afficher Notification d\'abonné + Voir Mentions de Story + Désactiver Suggestions de personnes + Copier Commentaire + + + Copier Commentaire + Copier le commentaire complet + Sélectionner une partie à copier + Sélectionner pour copier + Copier la sélection + ✅ Copié dans le presse-papiers ! + + + ⚙ Paramètres Téléchargeur + Télécharger les Publications + Télécharger les Stories + Télécharger les Reels + Télécharger les Photos de Profil + 📁 Dossier de téléchargement + Sauvegarder dans Sous-dossier utilisateur + Ajouter Horodatage au Nom de fichier + Impossible d\'ouvrir le sélecteur de dossier ici + + + Sauvegarder les Paramètres + Restaurer les Paramètres + Échec de création de sauvegarde : %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ Vider le cache et redémarrer ? + Redémarrer Maintenant + Impossible de trouver l\'app pour redémarrer. + Échec du redémarrage : %1$s + + + Aucune option Mode Fantôme sélectionnée ! + Mode Fantôme Activé + Mode Fantôme Désactivé + ✅ Lu envoyé + ✅ Lu canal envoyé + + + vous suit ✅ + ne vous suit pas ❌ + + + Paramètres appliqués ! + Veuillez fermer Instagram manuellement pour appliquer les changements. + Paramètres restaurés avec succès. + Échec de restauration : %1$s + + + Dossier de téléchargement : %1$s + Dossier de téléchargement mis à jour ! + Dossier de téléchargement réinitialisé par défaut + + + Téléchargement… + Téléchargement de la vidéo… + Téléchargement de la photo… + Vidéo sauvegardée dans le dossier InstaEclipse + Photo sauvegardée dans le dossier InstaEclipse + Sauvegardé dans le dossier InstaEclipse + Échec du téléchargement : %1$s + Téléchargement du reel… + Reel sauvegardé dans le dossier InstaEclipse + Échec du téléchargement du reel : %1$s + URL du reel introuvable + URL de la publication introuvable + Aucun média trouvé pour cette publication + Aucun média trouvé + Téléchargement de la vidéo de la story… + Téléchargement de la photo de la story… + Story sauvegardée dans le dossier InstaEclipse + URL de la story introuvable + Téléchargement de la photo de profil… + Photo de profil sauvegardée ! + Impossible d\'obtenir l\'URL de la photo de profil + Fusion vidéo + audio… + Téléchargement de %1$d éléments… + Téléchargement de tous les %1$d éléments… + Tous les %1$d éléments sauvegardés dans le dossier InstaEclipse + %1$d sur %2$d éléments sauvegardés (%3$d échoués) + URL du reel introuvable — faites défiler le reel d\'abord + + + Télécharger + Carrousel • %1$d éléments + Télécharger actuel (%1$d sur %2$d) + Télécharger tous les %1$d éléments + + + Voir les Mentions + @%1$s copié + Toutes les mentions copiées + + + Mentions de Story + %1$d mention(s) • appuyer pour copier + Aucune mention trouvée dans cette story + Tout copier +
diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index 48f89260..5b96e4c6 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -1,30 +1,28 @@ InstaEclipse + v0.5 Beta דף הבית תכונות עזרה + גרסה - גרסה + חזרה + החל שינויים + ⚠️ זוהו מספר גרסאות המודול אינו מופעל. נא להפעיל אותו. - סטטוס המודול: - סטטוס המודול: אינו מופעל נא להפעיל את המודול בXposed. - סטטוס המודול: מופעל )בלי גישה לRoot) גישה לRoot נדרש עבור תכונת הauto-restart. הפעלה מחדש ידנית נדרשת. - סטטוס המודול: תקין המודול תקין ועובד. - בודק אינסטגרם אינסטגרם מותקן אינסטגרם אינו מותקן שגיאה במהלך בדיקת סטטוס אינסטגרם - הפעל אינסטגרם הורדת APK הוראות שימוש @@ -33,6 +31,8 @@ תודה מיוחדת + Instagram Logo + instagram_info עזרה & פתירת בעיות @@ -40,8 +40,12 @@ פתיחת GitHub מציאת תמיכה. פתיחת Telegram + InstaEclipse Telegram Group + InstaEclipse GitHub + GitHub + Telegram שאלות נפוצות @@ -54,6 +58,20 @@ המודול עדיין לא עובד? + rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow this guide]]>
+ + + קטגוריות: + כלים: + תכונות: + הגדרות: + אפשרויות: + החלפה מהירה: + אזור מסוכן: + תיקיית הורדות: + 📂 הגדר מיקום תיקיית הורדות + 📂 נבחר: %1$s + 🗑 אפס תיקיית הורדות הגדרות מפתחים @@ -61,10 +79,10 @@ ללא הסחות דעת מחיקת פרסומות מחיקת אנליטיקות - הפעלה/השבתת הכל + Hint הגדרות מצב רפאים @@ -80,13 +98,15 @@ מדריך הגדרות מפתחים נא להשתמש רק על גרסאות Beta או Alpha בלבד! נא לוודא שאתם כבר מחוברים לחשבון שלכם! - 1. לפתוח אינסטגרם וללחוץ ארוך על כפתור עמוד הבית. 2. לגשת להגדרת מפתחים > הגדרות MetaConfig & Overrides. 3. לחפש \'Employee\' ולהפעיל את ההגדרות הבאות: 4. לסוף, השבת את ההגדרות מפתחים מהמודול כדי למנות קריסות אפליקציה. + • is employee + • is employee or test user + • employee options הגדרות מצב "ללא הסחות דעת" @@ -107,4 +127,219 @@ שם התורם -
\ No newline at end of file + + + + + + InstaEclipse 🌘 + סגור + הפעלה/השבתת הכל + שגיאה + אישור + כן + ביטול + + + 🎛 הגדרות מפתחים + 👻 הגדרות מצב רפאים + 🛡 חסימת פרסומות/אנליטיקות + 🧘 אינסטגרם ללא הסחות דעת + ⚙ תכונות נוספות + 📥 הורדה + 💾 גיבוי & שחזור + ℹ️ אודות + 🔁 הפעל מחדש את האפליקציה + + + הגדרות מפתחים 🎛 + מצב רפאים 👻 + חסימת פרסומות/אנליטיקות 🛡️ + אינסטגרם ללא הסחות דעת 🧘 + שונות ⚙️ + הורדה 📥 + הגדרות הורדה ⚙️ + גיבוי & שחזור 💾 + אודות + הפעל מחדש את האפליקציה + התאם אישית החלפה מהירה 🛠️ + + + הפעל מצב מפתחים + ייבא הגדרות מפתחים + ייצא הגדרות מפתחים + הסר חלונית Build Expired + לא ניתן לפתוח את ממשק InstaEclipse. + אינסטגרם אינו פתוח או מוכן. + mc_overrides.json לא נמצא. + נכשל בקריאת הגדרות: %1$s + לא התקבלו נתוני הגדרות. + ההגדרות יוצאו בהצלחה. + נכשל בשמירה: %1$s + URI לא תקין + ✅ ההגדרות יובאו. + ❌ נכשל בייבוא הגדרות. + + + בחר קובץ JSON + ❌ חבילת יעד לא צוינה. + ✅ נשלח לאינסטגרם. + ❌ קובץ JSON לא תקין. + ❌ נכשל בקריאת קובץ: %1$s + בוטל או לא נבחר קובץ. + + + הסתר קריאת הודעות + הסתר מחוון הקלדה + הסתר צפיות בסטורי + הסתר נוכחות בLive + אפשר צילומי מסך בהודעות + עקוף זיהוי צילום מסך + הסתר פתיחת מדיה חד-פעמית + צפיות חוזרות ללא הגבלה + ⚠️ מדיה חד-פעמית קבועה + שמור הודעות נעלמות + 🛠 התאם אישית החלפה מהירה + + + כלול הסתר קריאת הודעות + כלול הסתר מחוון הקלדה + כלול עקוף זיהוי צילום מסך + כלול הסתר פתיחת מדיה חד-פעמית + כלול הסתר צפיות בסטורי + כלול הסתר נוכחות בLive + כלול שמור הודעות נעלמות + כלול צפיות חוזרות ללא הגבלה + כלול מדיה חד-פעמית קבועה + כלול אפשר צילומי מסך בהודעות + + + חסום פרסומות + חסום אנליטיקות + השבת קישורי מעקב + + + מצב קיצוני 🔒 (בלתי הפיך עד להתקנה מחדש) + השבת Stories + השבת פיד + השבת ריילז + השבת ריילז מלבד בהודעות + השבת Explore + השבת תגובות + הפעל מצב קיצוני? + לאחר הפעלה, לא ניתן להשבית את מצב ללא הסחות דעת עד להתקנה מחדש של האפליקציה. להמשיך? + + + השבת מעבר אוטומטי בין סטורי + השבת ניגון אוטומטי של סרטונים + השבת שיתוף מחדש + הצג הודעות תכונות + הצג הודעת עוקב + צפה באזכורי סטורי + השבת הצעות אנשים + העתק תגובה + + + העתק תגובה + העתק תגובה מלאה + בחר חלק להעתקה + בחר להעתקה + העתק נבחר + ✅ הועתק ללוח! + + + ⚙ הגדרות הורדה + הורד פוסטים + הורד סטוריז + הורד ריילז + הורד תמונות פרופיל + 📁 תיקיית הורדות + שמור בתיקיית משנה של משתמש + הוסף חותמת זמן לשם קובץ + לא ניתן לפתוח בורר תיקיות כאן + + + גבה הגדרות + שחזר הגדרות + נכשל ביצירת גיבוי: %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ נקה cache ואתחל? + אתחל עכשיו + לא נמצאה האפליקציה לאתחול. + האתחול נכשל: %1$s + + + לא נבחרו אפשרויות מצב רפאים! + מצב רפאים הופעל + מצב רפאים הושבת + ✅ נקראה נשלחה + ✅ נקראה ערוץ נשלחה + + + עוקב אחריך ✅ + לא עוקב אחריך ❌ + + + ההגדרות הוחלו! + נא לסגור את אינסטגרם ידנית כדי שהשינויים ייכנסו לתוקף. + ההגדרות שוחזרו בהצלחה. + השחזור נכשל: %1$s + + + תיקיית הורדות: %1$s + תיקיית הורדות עודכנה! + תיקיית הורדות אופסה לברירת מחדל + + + מוריד… + מוריד סרטון… + מוריד תמונה… + הסרטון נשמר לתיקיית InstaEclipse + התמונה נשמרה לתיקיית InstaEclipse + נשמר לתיקיית InstaEclipse + ההורדה נכשלה: %1$s + מוריד ריל… + הריל נשמר לתיקיית InstaEclipse + הורדת ריל נכשלה: %1$s + כתובת URL של ריל לא נמצאה + כתובת URL של פוסט לא נמצאה + לא נמצא מדיה לפוסט זה + לא נמצא מדיה + מוריד סרטון סטורי… + מוריד תמונת סטורי… + הסטורי נשמר לתיקיית InstaEclipse + כתובת URL של סטורי לא נמצאה + מוריד תמונת פרופיל… + תמונת הפרופיל נשמרה! + לא ניתן לקבל כתובת URL של תמונת פרופיל + ממזג וידאו + אודיו… + מוריד %1$d פריטים… + מוריד את כל %1$d הפריטים… + כל %1$d הפריטים נשמרו לתיקיית InstaEclipse + %1$d מתוך %2$d פריטים נשמרו (%3$d נכשלו) + כתובת URL של ריל לא נמצאה — גלול את הריל תחילה + + + הורד + קרוסלה • %1$d פריטים + הורד נוכחי (%1$d מתוך %2$d) + הורד את כל %1$d הפריטים + + + צפה באזכורים + @%1$s הועתק + כל האזכורים הועתקו + + + אזכורי סטורי + %1$d אזכור(ים) • הקש להעתקה + לא נמצאו אזכורים בסטורי זה + העתק הכל + + diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index bb3e93c0..35dddd61 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -1,31 +1,28 @@ InstaEclipse - v0.4.2 Beta + v0.5 Beta Beranda Fitur Bantuan Versi + Kembali + Terapkan Perubahan + ⚠️ Beberapa versi terdeteksi Modul tidak aktif. Silakan aktifkan. - Status Modul: - Status Modul: Dinonaktifkan Silakan aktifkan modul di Xposed Installer. - Status Modul: Diaktifkan (Tanpa Akses Root) Akses root diperlukan untuk memulai ulang otomatis. Diperlukan restart manual. - Status Modul: Diaktifkan Modul aktif dan berfungsi. - Memeriksa Instagram Instagram terpasang Instagram tidak terpasang Kesalahan saat memeriksa status Instagram - Luncurkan Instagram Unduh APK Cara Penggunaan @@ -43,7 +40,7 @@ Buka GitHub Temukan dukungan. Buka Telegram - Grup Telegram InstaEclipse + InstaEclipse Telegram Group InstaEclipse GitHub @@ -63,13 +60,25 @@ Modul masih tidak berfungsi? root, coba gunakan LSPosed dari JingMatrix

Jika Anda tidak memiliki root, instal LSPatch dari JingMatrix lalu ikuti panduan ini]]>
+ + Kategori: + Alat: + Fitur: + Konfigurasi: + Opsi: + Sakelar Cepat: + Zona Bahaya: + Folder Unduhan: + 📂 Atur Lokasi Folder Unduhan + 📂 Dipilih: %1$s + 🗑 Reset Folder Unduhan + Opsi Pengembang Mode Hantu Bebas Gangguan Hapus Iklan Hapus Analitik - Aktifkan/Nonaktifkan Semua @@ -89,7 +98,6 @@ Panduan Opsi Pengembang Gunakan hanya pada versi Beta/Alpha! Pastikan Anda sudah masuk terlebih dahulu! - 1. Buka Instagram dan tahan tombol Beranda. 2. Arahkan ke Opsi Pengembang > Pengaturan & Penggantian MetaConfig. 3. Cari \'Employee\' dan aktifkan opsi berikut: @@ -119,4 +127,219 @@ Nama Kontributor + + + + + + InstaEclipse 🌘 + Tutup + Aktifkan/Nonaktifkan Semua + Kesalahan + OK + Ya + Batal + + + 🎛 Opsi Pengembang + 👻 Pengaturan Mode Hantu + 🛡 Blokir Iklan/Analitik + 🧘 Instagram Bebas Gangguan + ⚙ Fitur Lainnya + 📥 Pengunduh + 💾 Cadangan & Pemulihan + ℹ️ Tentang + 🔁 Mulai Ulang Aplikasi + + + Opsi Pengembang 🎛 + Mode Hantu 👻 + Blokir Iklan/Analitik 🛡️ + Instagram Bebas Gangguan 🧘 + Lainnya ⚙️ + Pengunduh 📥 + Pengaturan Pengunduh ⚙️ + Cadangan & Pemulihan 💾 + Tentang + Mulai Ulang Aplikasi + Sesuaikan Sakelar Cepat 🛠️ + + + Aktifkan Mode Pengembang + Impor Konfigurasi Dev + Ekspor Konfigurasi Dev + Hapus Popup Build Kedaluwarsa + Tidak dapat membuka antarmuka InstaEclipse. + Instagram tidak terbuka atau belum siap. + mc_overrides.json tidak ditemukan. + Gagal membaca konfigurasi: %1$s + Tidak ada data konfigurasi yang diterima. + Konfigurasi berhasil diekspor. + Gagal menyimpan: %1$s + URI tidak valid + ✅ Konfigurasi diimpor. + ❌ Gagal mengimpor konfigurasi. + + + Pilih File JSON + ❌ Paket target tidak ditentukan. + ✅ Dikirim ke Instagram. + ❌ Bukan file JSON yang valid. + ❌ Gagal membaca file: %1$s + Dibatalkan atau tidak ada file yang dipilih. + + + Sembunyikan Tanda Baca DM + Sembunyikan Indikator Mengetik + Sembunyikan Tampilan Story + Sembunyikan Kehadiran Live + Izinkan Tangkapan Layar di DM + Lewati Deteksi Tangkapan Layar + Sembunyikan Pembukaan Sekali Lihat + Putar Ulang Sekali Lihat Tanpa Batas + ⚠️ Media Sekali Lihat Permanen + Simpan Pesan yang Menghilang + 🛠 Sesuaikan Sakelar Cepat + + + Sertakan Sembunyikan Tanda Baca DM + Sertakan Sembunyikan Indikator Mengetik + Sertakan Lewati Deteksi Tangkapan Layar + Sertakan Sembunyikan Pembukaan Sekali Lihat + Sertakan Sembunyikan Tampilan Story + Sertakan Sembunyikan Kehadiran Live + Sertakan Simpan Pesan yang Menghilang + Sertakan Putar Ulang Sekali Lihat Tanpa Batas + Sertakan Media Sekali Lihat Permanen + Sertakan Izinkan Tangkapan Layar di DM + + + Blokir Iklan + Blokir Analitik + Nonaktifkan Tautan Pelacakan + + + Mode Ekstrem 🔒 (Tidak dapat dipulihkan hingga reinstall) + Nonaktifkan Story + Nonaktifkan Feed + Nonaktifkan Reels + Nonaktifkan Reels Kecuali di DM + Nonaktifkan Explore + Nonaktifkan Komentar + Aktifkan Mode Ekstrem? + Setelah diaktifkan, Anda tidak dapat menonaktifkan Mode Bebas Gangguan hingga menginstal ulang aplikasi. Lanjutkan? + + + Nonaktifkan Auto-Geser Story + Nonaktifkan Putar Otomatis Video + Nonaktifkan Repost + Tampilkan Toast Fitur + Tampilkan Toast Pengikut + Lihat Sebutan Story + Nonaktifkan Temukan Orang + Salin Komentar + + + Salin Komentar + Salin Komentar Lengkap + Pilih Bagian untuk Disalin + Pilih untuk Disalin + Salin yang Dipilih + ✅ Disalin ke clipboard! + + + ⚙ Pengaturan Pengunduh + Unduh Postingan + Unduh Story + Unduh Reels + Unduh Foto Profil + 📁 Folder Unduhan + Simpan di Subfolder Nama Pengguna + Tambahkan Stempel Waktu ke Nama File + Tidak dapat membuka pemilih folder di sini + + + Cadangkan Pengaturan + Pulihkan Pengaturan + Gagal membuat cadangan: %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ Hapus cache aplikasi dan mulai ulang? + Mulai Ulang Sekarang + Tidak dapat menemukan aplikasi untuk dimulai ulang. + Mulai ulang gagal: %1$s + + + Tidak ada opsi Mode Hantu yang dipilih! + Mode Hantu Diaktifkan + Mode Hantu Dinonaktifkan + ✅ Tanda Baca Terkirim + ✅ Tanda Baca Saluran Terkirim + + + mengikuti Anda ✅ + tidak mengikuti Anda ❌ + + + Pengaturan Diterapkan! + Harap tutup Instagram secara manual agar perubahan berlaku. + Pengaturan berhasil dipulihkan. + Pemulihan gagal: %1$s + + + Folder unduhan: %1$s + Folder unduhan diperbarui! + Folder unduhan direset ke Default + + + Mengunduh… + Mengunduh video… + Mengunduh foto… + Video disimpan ke folder InstaEclipse + Foto disimpan ke folder InstaEclipse + Disimpan ke folder InstaEclipse + Unduhan gagal: %1$s + Mengunduh reel… + Reel disimpan ke folder InstaEclipse + Unduhan reel gagal: %1$s + URL reel tidak ditemukan + URL postingan tidak ditemukan + Tidak ada media ditemukan untuk postingan ini + Tidak ada media ditemukan + Mengunduh video story… + Mengunduh foto story… + Story disimpan ke folder InstaEclipse + URL story tidak ditemukan + Mengunduh foto profil… + Foto profil disimpan! + Tidak dapat mendapatkan URL foto profil + Menggabungkan video + audio… + Mengunduh %1$d item… + Mengunduh semua %1$d item… + Semua %1$d item disimpan ke folder InstaEclipse + %1$d dari %2$d item disimpan (%3$d gagal) + URL reel tidak ditemukan — gulir reel terlebih dahulu + + + Unduh + Carousel • %1$d item + Unduh saat ini (%1$d dari %2$d) + Unduh semua %1$d item + + + Lihat Sebutan + @%1$s disalin + Semua sebutan disalin + + + Sebutan Story + %1$d sebutan • ketuk untuk menyalin + Tidak ada sebutan ditemukan dalam story ini + Salin Semua +
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 2d1c38ef..1497bb2b 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,30 +1,28 @@ InstaEclipse + v0.5 Beta דף הבית תכונות עזרה + גרסה - גרסה + חזרה + החל שינויים + ⚠️ זוהו מספר גרסאות המודול אינו מופעל. נא להפעיל אותו. - סטטוס המודול: - סטטוס המודול: אינו מופעל נא להפעיל את המודול בXposed. - סטטוס המודול: מופעל )בלי גישה לRoot) גישה לRoot נדרש עבור תכונת הauto-restart. הפעלה מחדש ידנית נדרשת. - סטטוס המודול: תקין המודול תקין ועובד. - בודק אינסטגרם אינסטגרם מותקן אינסטגרם אינו מותקן שגיאה במהלך בדיקת סטטוס אינסטגרם - הפעל אינסטגרם הורדת APK הוראות שימוש @@ -33,6 +31,8 @@ תודה מיוחדת + Instagram Logo + instagram_info עזרה & פתירת בעיות @@ -40,8 +40,12 @@ פתיחת GitHub מציאת תמיכה. פתיחת Telegram - + InstaEclipse Telegram Group + + InstaEclipse GitHub + GitHub + Telegram שאלות נפוצות @@ -54,6 +58,20 @@ המודול עדיין לא עובד? + rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow this guide]]>
+ + + קטגוריות: + כלים: + תכונות: + הגדרות: + אפשרויות: + החלפה מהירה: + אזור מסוכן: + תיקיית הורדות: + 📂 הגדר מיקום תיקיית הורדות + 📂 נבחר: %1$s + 🗑 אפס תיקיית הורדות הגדרות מפתחים @@ -61,10 +79,10 @@ ללא הסחות דעת מחיקת פרסומות מחיקת אנליטיקות - הפעלה/השבתת הכל + Hint הגדרות מצב רפאים @@ -80,13 +98,15 @@ מדריך הגדרות מפתחים נא להשתמש רק על גרסאות Beta או Alpha בלבד! נא לוודא שאתם כבר מחוברים לחשבון שלכם! - 1. לפתוח אינסטגרם וללחוץ ארוך על כפתור עמוד הבית. 2. לגשת להגדרת מפתחים > הגדרות MetaConfig & Overrides. 3. לחפש \'Employee\' ולהפעיל את ההגדרות הבאות: 4. לסוף, השבת את ההגדרות מפתחים מהמודול כדי למנות קריסות אפליקציה. + • is employee + • is employee or test user + • employee options הגדרות מצב "ללא הסחות דעת" @@ -107,4 +127,219 @@ שם התורם -
+ + + + + + InstaEclipse 🌘 + סגור + הפעלה/השבתת הכל + שגיאה + אישור + כן + ביטול + + + 🎛 הגדרות מפתחים + 👻 הגדרות מצב רפאים + 🛡 חסימת פרסומות/אנליטיקות + 🧘 אינסטגרם ללא הסחות דעת + ⚙ תכונות נוספות + 📥 הורדה + 💾 גיבוי & שחזור + ℹ️ אודות + 🔁 הפעל מחדש את האפליקציה + + + הגדרות מפתחים 🎛 + מצב רפאים 👻 + חסימת פרסומות/אנליטיקות 🛡️ + אינסטגרם ללא הסחות דעת 🧘 + שונות ⚙️ + הורדה 📥 + הגדרות הורדה ⚙️ + גיבוי & שחזור 💾 + אודות + הפעל מחדש את האפליקציה + התאם אישית החלפה מהירה 🛠️ + + + הפעל מצב מפתחים + ייבא הגדרות מפתחים + ייצא הגדרות מפתחים + הסר חלונית Build Expired + לא ניתן לפתוח את ממשק InstaEclipse. + אינסטגרם אינו פתוח או מוכן. + mc_overrides.json לא נמצא. + נכשל בקריאת הגדרות: %1$s + לא התקבלו נתוני הגדרות. + ההגדרות יוצאו בהצלחה. + נכשל בשמירה: %1$s + URI לא תקין + ✅ ההגדרות יובאו. + ❌ נכשל בייבוא הגדרות. + + + בחר קובץ JSON + ❌ חבילת יעד לא צוינה. + ✅ נשלח לאינסטגרם. + ❌ קובץ JSON לא תקין. + ❌ נכשל בקריאת קובץ: %1$s + בוטל או לא נבחר קובץ. + + + הסתר קריאת הודעות + הסתר מחוון הקלדה + הסתר צפיות בסטורי + הסתר נוכחות בLive + אפשר צילומי מסך בהודעות + עקוף זיהוי צילום מסך + הסתר פתיחת מדיה חד-פעמית + צפיות חוזרות ללא הגבלה + ⚠️ מדיה חד-פעמית קבועה + שמור הודעות נעלמות + 🛠 התאם אישית החלפה מהירה + + + כלול הסתר קריאת הודעות + כלול הסתר מחוון הקלדה + כלול עקוף זיהוי צילום מסך + כלול הסתר פתיחת מדיה חד-פעמית + כלול הסתר צפיות בסטורי + כלול הסתר נוכחות בLive + כלול שמור הודעות נעלמות + כלול צפיות חוזרות ללא הגבלה + כלול מדיה חד-פעמית קבועה + כלול אפשר צילומי מסך בהודעות + + + חסום פרסומות + חסום אנליטיקות + השבת קישורי מעקב + + + מצב קיצוני 🔒 (בלתי הפיך עד להתקנה מחדש) + השבת Stories + השבת פיד + השבת ריילז + השבת ריילז מלבד בהודעות + השבת Explore + השבת תגובות + הפעל מצב קיצוני? + לאחר הפעלה, לא ניתן להשבית את מצב ללא הסחות דעת עד להתקנה מחדש של האפליקציה. להמשיך? + + + השבת מעבר אוטומטי בין סטורי + השבת ניגון אוטומטי של סרטונים + השבת שיתוף מחדש + הצג הודעות תכונות + הצג הודעת עוקב + צפה באזכורי סטורי + השבת הצעות אנשים + העתק תגובה + + + העתק תגובה + העתק תגובה מלאה + בחר חלק להעתקה + בחר להעתקה + העתק נבחר + ✅ הועתק ללוח! + + + ⚙ הגדרות הורדה + הורד פוסטים + הורד סטוריז + הורד ריילז + הורד תמונות פרופיל + 📁 תיקיית הורדות + שמור בתיקיית משנה של משתמש + הוסף חותמת זמן לשם קובץ + לא ניתן לפתוח בורר תיקיות כאן + + + גבה הגדרות + שחזר הגדרות + נכשל ביצירת גיבוי: %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ נקה cache ואתחל? + אתחל עכשיו + לא נמצאה האפליקציה לאתחול. + האתחול נכשל: %1$s + + + לא נבחרו אפשרויות מצב רפאים! + מצב רפאים הופעל + מצב רפאים הושבת + ✅ נקראה נשלחה + ✅ נקראה ערוץ נשלחה + + + עוקב אחריך ✅ + לא עוקב אחריך ❌ + + + ההגדרות הוחלו! + נא לסגור את אינסטגרם ידנית כדי שהשינויים ייכנסו לתוקף. + ההגדרות שוחזרו בהצלחה. + השחזור נכשל: %1$s + + + תיקיית הורדות: %1$s + תיקיית הורדות עודכנה! + תיקיית הורדות אופסה לברירת מחדל + + + מוריד… + מוריד סרטון… + מוריד תמונה… + הסרטון נשמר לתיקיית InstaEclipse + התמונה נשמרה לתיקיית InstaEclipse + נשמר לתיקיית InstaEclipse + ההורדה נכשלה: %1$s + מוריד ריל… + הריל נשמר לתיקיית InstaEclipse + הורדת ריל נכשלה: %1$s + כתובת URL של ריל לא נמצאה + כתובת URL של פוסט לא נמצאה + לא נמצא מדיה לפוסט זה + לא נמצא מדיה + מוריד סרטון סטורי… + מוריד תמונת סטורי… + הסטורי נשמר לתיקיית InstaEclipse + כתובת URL של סטורי לא נמצאה + מוריד תמונת פרופיל… + תמונת הפרופיל נשמרה! + לא ניתן לקבל כתובת URL של תמונת פרופיל + ממזג וידאו + אודיו… + מוריד %1$d פריטים… + מוריד את כל %1$d הפריטים… + כל %1$d הפריטים נשמרו לתיקיית InstaEclipse + %1$d מתוך %2$d פריטים נשמרו (%3$d נכשלו) + כתובת URL של ריל לא נמצאה — גלול את הריל תחילה + + + הורד + קרוסלה • %1$d פריטים + הורד נוכחי (%1$d מתוך %2$d) + הורד את כל %1$d הפריטים + + + צפה באזכורים + @%1$s הועתק + כל האזכורים הועתקו + + + אזכורי סטורי + %1$d אזכור(ים) • הקש להעתקה + לא נמצאו אזכורים בסטורי זה + העתק הכל + + \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index ca9629f9..098efa9d 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -29,6 +29,7 @@ Baixar APK Como usar O módulo ainda não está funcionando? + root, experimente usar o LSPosed do JingMatrix

Se não tiver root, instale o LSPatch do JingMatrix e depois siga este guia]]>
Mantenha pressionado o ícone de pesquisa Colaboradores @@ -109,4 +110,204 @@ Status de seguimento + + + Fechar + Ativar/Desativar tudo + Erro + OK + Sim + Cancelar + 🎛 Opções de desenvolvedor + 👻 Configurações do modo fantasma + 🛡 Bloquear anúncios/análises + 🧘 Instagram sem distrações + ⚙ Funções diversas + 📥 Transferidor + 💾 Backup e restauração + ℹ️ Sobre + 🔁 Reiniciar aplicação + Opções de desenvolvedor 🎛 + Modo fantasma 👻 + Bloquear anúncios/análises 🛡️ + Instagram sem distrações 🧘 + Miscelânea ⚙️ + Transferidor 📥 + Configurações do transferidor ⚙️ + Backup e restauração 💾 + Sobre + Reiniciar aplicação + Personalizar ativação rápida 🛠️ + Ativar modo de desenvolvedor + Importar configuração do desenvolvedor + Exportar configuração do desenvolvedor + Remover pop-up de build expirada + Não foi possível abrir a interface do InstaEclipse. + Instagram não está aberto ou não está pronto. + mc_overrides.json não encontrado. + Falha ao ler configuração: %1$s + Nenhum dado de configuração recebido. + Configuração exportada com sucesso. + Falha ao salvar: %1$s + URI inválida + ✅ Configuração importada. + ❌ Falha ao importar configuração. + + + Selecionar config JSON + ❌ Pacote de destino não especificado. + ✅ Enviado ao Instagram. + ❌ Não é um ficheiro JSON válido. + ❌ Falha ao ler ficheiro: %1$s + Cancelado ou nenhum ficheiro selecionado. + Ocultar leitura em mensagens diretas + Ocultar indicador de digitação + Ocultar visualizações de stories + Ocultar presença em live + Permitir capturas de ecrã em mensagens diretas + Contornar deteção de capturas de ecrã + Ocultar abertura de mensagens temporárias + Repetições ilimitadas de visualização única + ⚠️ Tornar mídia temporária permanente + Guardar mensagens que desaparecem + 🛠 Personalizar ativação rápida + Incluir ocultar leitura em mensagens diretas + Incluir ocultar indicador de digitação + Incluir contornar deteção de capturas de ecrã + Incluir ocultar abertura de mensagens temporárias + Incluir ocultar visualizações de stories + Incluir ocultar presença em live + Incluir guardar mensagens que desaparecem + Incluir repetições ilimitadas de visualização única + Incluir tornar mídia temporária permanente + Incluir permitir capturas de ecrã em mensagens diretas + Bloquear anúncios + Bloquear análises + Desativar links de rastreamento + Modo extremo 🔒 (Irreversível até reinstalar) + Desativar stories + Desativar feed + Desativar Reels + Desativar Reels exceto em mensagens diretas + Desativar explorar + Desativar comentários + Ativar o modo extremo? + Uma vez ativado, não poderá desativar o modo sem distrações até reinstalar a aplicação. Continuar? + Desativar avanço automático de stories + Desativar reprodução automática de vídeo + Desativar repostagem + Mostrar notificações de funcionalidades + Mostrar notificação de seguidor + Ver menções em stories + Desativar descobrir pessoas + Copiar comentário + + + Copiar comentário + Copiar comentário completo + Selecionar parte para copiar + Selecionar para copiar + Copiar seleção + ✅ Copiado para a área de transferência! + ⚙ Configurações do transferidor + Transferir publicações + Transferir stories + Transferir Reels + Transferir fotos de perfil + 📁 Pasta de transferência + Guardar em subpasta com nome de utilizador + Adicionar marca de tempo ao nome do ficheiro + Não é possível abrir o seletor de pastas aqui + Backup de configurações + Restaurar configurações + Falha ao criar backup: %1$s + GitHub + Telegram + ⚠️ Limpar cache da aplicação e reiniciar? + Reiniciar agora + Não foi possível encontrar a aplicação para reiniciar. + Falha ao reiniciar: %1$s + + + Nenhuma opção do Modo Fantasma selecionada! + Modo Fantasma ativado + Modo Fantasma desativado + ✅ Lido enviado + ✅ Canal lido enviado + + + segue você ✅ + não segue você ❌ + + + Configurações aplicadas! + Por favor, feche o Instagram manualmente para as alterações surtirem efeito. + Configurações restauradas com sucesso. + Falha ao restaurar: %1$s + + + Pasta de download: %1$s + Pasta de download atualizada! + Pasta de download redefinida para padrão + + + A descarregar… + A descarregar vídeo… + A descarregar foto… + Vídeo guardado na pasta InstaEclipse + Foto guardada na pasta InstaEclipse + Guardado na pasta InstaEclipse + Falha no download: %1$s + A descarregar reel… + Reel guardado na pasta InstaEclipse + Falha no download do reel: %1$s + URL do reel não encontrado + URL da publicação não encontrado + Nenhum ficheiro de média encontrado para esta publicação + Nenhum ficheiro de média encontrado + A descarregar vídeo de história… + A descarregar foto de história… + História guardada na pasta InstaEclipse + URL da história não encontrado + A descarregar foto de perfil… + Foto de perfil guardada! + Não foi possível obter o URL da foto de perfil + A juntar vídeo e áudio… + A descarregar %1$d itens… + A descarregar todos os %1$d itens… + Todos os %1$d itens guardados na pasta InstaEclipse + %1$d de %2$d itens guardados (%3$d falhados) + URL do reel não encontrado — deslize o reel primeiro + Descarregar + Carrossel • %1$d itens + Descarregar atual (%1$d de %2$d) + Descarregar todos os %1$d itens + Ver menções + @%1$s copiado + Todas as menções copiadas + + Voltar + Aplicar alterações + + + Categorias: + Ferramentas: + Funcionalidades: + Configuração: + Opções: + Ativação rápida: + Zona de perigo: + Pasta de transferência: + 📂 Definir pasta de transferência + 📂 Selecionada: %1$s + 🗑 Redefinir pasta de transferência + + + ⚠️ Várias versões detetadas + + + Menções de história + %1$d menção(ões) • toque para copiar + Nenhuma menção encontrada nesta história + Copiar tudo diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 449a83d7..08290c31 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -30,6 +30,7 @@ Скачать APK Как использовать Модуль всё ещё не работает? + root-доступ, попробуйте LSPosed от JingMatrix

Если у вас нет root-доступа, установите LSPatch от JingMatrix, затем следуйте этому руководству]]>
Удерживайте значок поиска Участники @@ -110,4 +111,205 @@ Статус подписки + + + Закрыть + Включить/Отключить всё + Ошибка + ОК + Да + Отмена + 🎛 Параметры разработчика + 👻 Настройки режима призрака + 🛡 Блокировка рекламы/аналитики + 🧘 Instagram без отвлечений + ⚙ Прочие функции + 📥 Загрузчик + 💾 Резервное копирование + ℹ️ О приложении + 🔁 Перезапустить приложение + Параметры разработчика 🎛 + Режим призрака 👻 + Блокировка рекламы/аналитики 🛡️ + Instagram без отвлечений 🧘 + Прочее ⚙️ + Загрузчик 📥 + Настройки загрузчика ⚙️ + Резервное копирование 💾 + О приложении + Перезапустить приложение + Настроить быстрое переключение 🛠️ + Включить режим разработчика + Импорт конфигурации разработчика + Экспорт конфигурации разработчика + Убрать всплывающее окно истечения сборки + Не удалось открыть интерфейс InstaEclipse. + Instagram не открыт или не готов. + Файл mc_overrides.json не найден. + Не удалось прочитать конфигурацию: %1$s + Данные конфигурации не получены. + Конфигурация успешно экспортирована. + Не удалось сохранить: %1$s + Недействительный URI + ✅ Конфигурация импортирована. + ❌ Не удалось импортировать конфигурацию. + + + Выбрать JSON-конфигурацию + ❌ Целевой пакет не указан. + ✅ Отправлено в Instagram. + ❌ Неверный формат JSON. + ❌ Не удалось прочитать файл: %1$s + Отменено или файл не выбран. + Скрыть прочтение в личных сообщениях + Скрыть индикатор набора текста + Скрыть просмотры историй + Скрыть присутствие в прямом эфире + Разрешить скриншоты в личных сообщениях + Обойти обнаружение скриншотов + Скрыть открытие одноразовых сообщений + Неограниченное воспроизведение + ⚠️ Сохранить временные медиа навсегда + Сохранять исчезающие сообщения + 🛠 Настроить быстрое переключение + Включить скрытие прочтения в личных сообщениях + Включить скрытие индикатора набора текста + Включить обход обнаружения скриншотов + Включить скрытие открытия одноразовых сообщений + Включить скрытие просмотров историй + Включить скрытие присутствия в прямом эфире + Включить сохранение исчезающих сообщений + Включить неограниченное воспроизведение + Включить сохранение временных медиа навсегда + Включить разрешение скриншотов в личных сообщениях + Блокировать рекламу + Блокировать аналитику + Отключить ссылки отслеживания + Экстремальный режим 🔒 (Необратимо до переустановки) + Отключить истории + Отключить ленту + Отключить Reels + Отключить Reels кроме личных сообщений + Отключить поиск + Отключить комментарии + Активировать экстремальный режим? + После активации вы не сможете отключить режим без отвлечений до переустановки приложения. Продолжить? + Отключить автопролистывание историй + Отключить автовоспроизведение видео + Отключить репост + Показывать уведомления о функциях + Показывать уведомление о подписчике + Просматривать упоминания в историях + Отключить рекомендации людей + Копировать комментарий + + + Копировать комментарий + Копировать весь комментарий + Выбрать часть для копирования + Выбрать для копирования + Копировать выбранное + ✅ Скопировано в буфер обмена! + ⚙ Настройки загрузчика + Загружать публикации + Загружать истории + Загружать Reels + Загружать фотографии профиля + 📁 Папка загрузки + Сохранять в подпапку с именем пользователя + Добавлять метку времени к имени файла + Невозможно открыть выбор папки здесь + Резервное копирование настроек + Восстановить настройки + Не удалось создать резервную копию: %1$s + GitHub + Telegram + ⚠️ Очистить кэш приложения и перезапустить? + Перезапустить сейчас + Не удалось найти приложение для перезапуска. + Перезапуск не удался: %1$s + + + Опции режима призрака не выбраны! + Режим призрака включён + Режим призрака отключён + ✅ Прочтено отправлено + ✅ Прочтено в канале отправлено + + + подписан на вас ✅ + не подписан на вас ❌ + + + Настройки применены! + Пожалуйста, закройте Instagram вручную для применения изменений. + Настройки успешно восстановлены. + Ошибка восстановления: %1$s + + + Папка загрузок: %1$s + Папка загрузок обновлена! + Папка загрузок сброшена до стандартной + + + Загрузка… + Загрузка видео… + Загрузка фото… + Видео сохранено в папку InstaEclipse + Фото сохранено в папку InstaEclipse + Сохранено в папку InstaEclipse + Ошибка загрузки: %1$s + Загрузка рила… + Рил сохранён в папку InstaEclipse + Ошибка загрузки рила: %1$s + URL рила не найден + URL публикации не найден + Медиафайлы для этой публикации не найдены + Медиафайлы не найдены + Загрузка видео истории… + Загрузка фото истории… + История сохранена в папку InstaEclipse + URL истории не найден + Загрузка фото профиля… + Фото профиля сохранено! + Не удалось получить URL фото профиля + Объединение видео и аудио… + Загрузка %1$d элементов… + Загрузка всех %1$d элементов… + Все %1$d элементов сохранены в папку InstaEclipse + %1$d из %2$d элементов сохранено (%3$d не удалось) + URL рила не найден — сначала прокрутите рил + Скачать + Карусель • %1$d элементов + Скачать текущее (%1$d из %2$d) + Скачать все %1$d элементов + Посмотреть упоминания + @%1$s скопировано + Все упоминания скопированы + + Назад + Применить изменения + + + Категории: + Инструменты: + Функции: + Конфигурация: + Параметры: + Быстрое переключение: + Опасная зона: + Папка загрузки: + 📂 Выбрать папку загрузки + 📂 Выбрана: %1$s + 🗑 Сбросить папку загрузки + + + ⚠️ Обнаружено несколько версий + + + Упоминания в истории + %1$d упомин. • нажмите для копирования + В этой истории не найдено упоминаний + Копировать все + diff --git a/app/src/main/res/values-se/strings.xml b/app/src/main/res/values-se/strings.xml index 14336b31..3fcfbb4e 100644 --- a/app/src/main/res/values-se/strings.xml +++ b/app/src/main/res/values-se/strings.xml @@ -29,6 +29,7 @@ Lasta APK Mo geavahit Modula ii doahttá ain? + rootad, prova att använda JingMatrix\'s LSPosed

Om du inte är rootad, installera JingMatrix\'s LSPatch och följ sedan den här guiden]]>
Doalvva gitta ohcamušikona Bidragsgivare @@ -109,4 +110,204 @@ Visa följarnotifikation + + + Stäng + Aktivera/inaktivera alla + Fel + OK + Ja + Avbryt + 🎛 Utvecklaralternativ + 👻 Spöklägeinställningar + 🛡 Blockera annonser/analys + 🧘 Instagram utan distraktioner + ⚙ Övriga funktioner + 📥 Nedladdare + 💾 Säkerhetskopiering och återställning + ℹ️ Om + 🔁 Starta om appen + Utvecklaralternativ 🎛 + Spökläge 👻 + Blockera annonser/analys 🛡️ + Instagram utan distraktioner 🧘 + Övrigt ⚙️ + Nedladdare 📥 + Nedladdningsinställningar ⚙️ + Säkerhetskopiering och återställning 💾 + Om + Starta om appen + Anpassa snabbväxling 🛠️ + Aktivera utvecklarläge + Importera utvecklarkonfiguration + Exportera utvecklarkonfiguration + Ta bort popup för utgången version + Kan inte öppna InstaEclipse-gränssnittet. + Instagram är inte öppet eller redo. + mc_overrides.json hittades inte. + Det gick inte att läsa konfigurationen: %1$s + Inga konfigurationsdata mottogs. + Konfigurationen exporterades. + Det gick inte att spara: %1$s + Ogiltig URI + ✅ Konfiguration importerad. + ❌ Import av konfiguration misslyckades. + + + Välj JSON-konfiguration + ❌ Målpaket ej angivet. + ✅ Skickat till Instagram. + ❌ Inte en giltig JSON-fil. + ❌ Det gick inte att läsa filen: %1$s + Avbrutet eller ingen fil vald. + Dölj läst i direktmeddelanden + Dölj skrivindikator + Dölj Story-visningar + Dölj live-närvaro + Tillåt skärmdumpar i direktmeddelanden + Kringgå skärmdumpsdetektering + Dölj öppning av engångsmeddelanden + Obegränsade uppspelningar + ⚠️ Gör tillfälliga medier permanenta + Behåll försvinnande meddelanden + 🛠 Anpassa snabbväxling + Inkludera dölj läst i direktmeddelanden + Inkludera dölj skrivindikator + Inkludera kringgå skärmdumpsdetektering + Inkludera dölj öppning av engångsmeddelanden + Inkludera dölj Story-visningar + Inkludera dölj live-närvaro + Inkludera behåll försvinnande meddelanden + Inkludera obegränsade uppspelningar + Inkludera gör tillfälliga medier permanenta + Inkludera tillåt skärmdumpar i direktmeddelanden + Blockera annonser + Blockera analys + Inaktivera spårningslänkar + Extremläge 🔒 (Oåterkalleligt tills ominstallation) + Inaktivera Stories + Inaktivera flöde + Inaktivera Reels + Inaktivera Reels utom i direktmeddelanden + Inaktivera utforska + Inaktivera kommentarer + Aktivera extremläge? + När det är aktiverat kan du inte inaktivera läget utan distraktioner förrän du installerar om appen. Fortsätt? + Inaktivera automatisk Story-bläddring + Inaktivera automatisk videouppspelning + Inaktivera repost + Visa funktionsaviseringar + Visa följaravisering + Visa Story-omnämnanden + Inaktivera Hitta personer + Kopiera kommentar + + + Kopiera kommentar + Kopiera hela kommentaren + Välj del att kopiera + Välj för att kopiera + Kopiera markerat + ✅ Kopierat till urklipp! + ⚙ Nedladdningsinställningar + Ladda ned inlägg + Ladda ned Stories + Ladda ned Reels + Ladda ned profilbilder + 📁 Nedladdningsmapp + Spara i undermapp med användarnamn + Lägg till tidsstämpel i filnamn + Kan inte öppna mappväljaren här + Säkerhetskopiera inställningar + Återställ inställningar + Det gick inte att skapa säkerhetskopia: %1$s + GitHub + Telegram + ⚠️ Rensa appcachen och starta om? + Starta om nu + Kunde inte hitta appen för omstart. + Omstart misslyckades: %1$s + + + Inga spöklägealternativ valda! + Spökläge aktiverat + Spökläge inaktiverat + ✅ Läst skickat + ✅ Kanal läst skickat + + + följer dig ✅ + följer dig inte ❌ + + + Inställningar tillämpade! + Stäng Instagram manuellt för att ändringarna ska träda i kraft. + Inställningar återställda. + Återställning misslyckades: %1$s + + + Nedladdningsmapp: %1$s + Nedladdningsmapp uppdaterad! + Nedladdningsmapp återställd till standard + + + Laddar ned… + Laddar ned video… + Laddar ned foto… + Video sparad i InstaEclipse-mappen + Foto sparat i InstaEclipse-mappen + Sparat i InstaEclipse-mappen + Nedladdning misslyckades: %1$s + Laddar ned reel… + Reel sparad i InstaEclipse-mappen + Reel-nedladdning misslyckades: %1$s + Reel-URL hittades inte + Inläggets URL hittades inte + Inga media hittades för detta inlägg + Inga media hittades + Laddar ned story-video… + Laddar ned story-foto… + Story sparad i InstaEclipse-mappen + Story-URL hittades inte + Laddar ned profilbild… + Profilbild sparad! + Kunde inte hämta profilbildens URL + Sammanfogar video och ljud… + Laddar ned %1$d objekt… + Laddar ned alla %1$d objekt… + Alla %1$d objekt sparade i InstaEclipse-mappen + %1$d av %2$d objekt sparade (%3$d misslyckades) + Reel-URL hittades inte — scrolla igenom reelen först + Ladda ned + Karusell • %1$d objekt + Ladda ned aktuellt (%1$d av %2$d) + Ladda ned alla %1$d objekt + Visa omnämnanden + @%1$s kopierat + Alla omnämnanden kopierade + + Tillbaka + Tillämpa ändringar + + + Kategorier: + Verktyg: + Funktioner: + Konfiguration: + Alternativ: + Snabbväxling: + Farlig zon: + Nedladdningsmapp: + 📂 Ange nedladdningsmapp + 📂 Vald: %1$s + 🗑 Återställ nedladdningsmapp + + + ⚠️ Flera versioner hittades + + + Story-omnämnanden + %1$d omnämnande(n) • tryck för att kopiera + Inga omnämnanden hittades i denna story + Kopiera alla diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 4bf611a7..f5d1b98b 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -30,6 +30,7 @@ APK indir Nasıl kullanılır Modül hâlâ çalışmıyor mu? + Root erişiminiz varsa JingMatrix\'in LSPosed uygulamasını kullanmayı deneyin

Root erişiminiz yoksa JingMatrix\'in LSPatch uygulamasını yükleyin ve ardından bu kılavuzu takip edin]]>
Arama simgesine uzun basın Katkıda Bulunanlar @@ -110,4 +111,204 @@ Takip durumu + + + Kapat + Tümünü etkinleştir/devre dışı bırak + Hata + Tamam + Evet + İptal + 🎛 Geliştirici Seçenekleri + 👻 Hayalet Modu Ayarları + 🛡 Reklam/Analitik Engeli + 🧘 Dikkat Dağıtıcısız Instagram + ⚙ Çeşitli Özellikler + 📥 İndirici + 💾 Yedekleme ve Geri Yükleme + ℹ️ Hakkında + 🔁 Uygulamayı Yeniden Başlat + Geliştirici Seçenekleri 🎛 + Hayalet Modu 👻 + Reklam/Analitik Engeli 🛡️ + Dikkat Dağıtıcısız Instagram 🧘 + Çeşitli ⚙️ + İndirici 📥 + İndirici Ayarları ⚙️ + Yedekleme ve Geri Yükleme 💾 + Hakkında + Uygulamayı Yeniden Başlat + Hızlı Geçişi Özelleştir 🛠️ + Geliştirici Modunu Etkinleştir + Geliştirici Yapılandırmasını İçe Aktar + Geliştirici Yapılandırmasını Dışa Aktar + Süresi Dolmuş Derleme Uyarısını Kaldır + InstaEclipse arayüzü açılamıyor. + Instagram açık değil veya hazır değil. + mc_overrides.json bulunamadı. + Yapılandırma okunamadı: %1$s + Yapılandırma verisi alınamadı. + Yapılandırma başarıyla dışa aktarıldı. + Kaydedilemedi: %1$s + Geçersiz URI + ✅ Yapılandırma içe aktarıldı. + ❌ Yapılandırma içe aktarılamadı. + + + JSON Yapılandırması Seç + ❌ Hedef paket belirtilmedi. + ✅ Instagram\'a gönderildi. + ❌ Geçerli bir JSON dosyası değil. + ❌ Dosya okunamadı: %1$s + İptal edildi veya dosya seçilmedi. + DM Okundu Bilgisini Gizle + Yazıyor Göstergesini Gizle + Hikaye Görüntülemelerini Gizle + Canlı Yayın Varlığını Gizle + DM\'lerde Ekran Görüntüsüne İzin Ver + Ekran Görüntüsü Tespitini Atla + Bir Kez Görüntüle Açılmasını Gizle + Sınırsız Tekrar İzleme + ⚠️ Geçici Medyayı Kalıcı Yap + Kaybolan Mesajları Sakla + 🛠 Hızlı Geçişi Özelleştir + DM Okundu Bilgisini Gizlemeyi Dahil Et + Yazıyor Göstergesini Gizlemeyi Dahil Et + Ekran Görüntüsü Tespitini Atlamayı Dahil Et + Bir Kez Görüntülemeyi Gizlemeyi Dahil Et + Hikaye Görüntülemeyi Gizlemeyi Dahil Et + Canlı Yayın Varlığını Gizlemeyi Dahil Et + Kaybolan Mesajları Saklamayı Dahil Et + Sınırsız Tekrar İzlemeyi Dahil Et + Geçici Medyayı Kalıcı Yapmayı Dahil Et + DM\'lerde Ekran Görüntüsüne İzin Vermeyi Dahil Et + Reklamları Engelle + Analitiği Engelle + Takip Bağlantılarını Devre Dışı Bırak + Aşırı Mod 🔒 (Yeniden yükleyene kadar geri alınamaz) + Hikayeleri Devre Dışı Bırak + Akışı Devre Dışı Bırak + Reels\'i Devre Dışı Bırak + DM\'ler Dışında Reels\'i Devre Dışı Bırak + Keşfet\'i Devre Dışı Bırak + Yorumları Devre Dışı Bırak + Aşırı Modu Etkinleştir? + Etkinleştirildikten sonra, uygulamayı yeniden yükleyene kadar Dikkat Dağıtıcısız Modu devre dışı bırakamazsınız. Devam etmek istiyor musunuz? + Hikaye Otomatik Geçişini Devre Dışı Bırak + Video Otomatik Oynatmayı Devre Dışı Bırak + Yeniden Paylaşımı Devre Dışı Bırak + Özellik Bildirimlerini Göster + Takipçi Bildirimini Göster + Hikaye Bahsetmelerini Görüntüle + Kişileri Keşfet\'i Devre Dışı Bırak + Yorumu Kopyala + + + Yorumu Kopyala + Tam Yorumu Kopyala + Kopyalanacak Kısmı Seç + Kopyalamak için Seç + Seçileni Kopyala + ✅ Panoya kopyalandı! + ⚙ İndirici Ayarları + Gönderileri İndir + Hikayeleri İndir + Reels\'i İndir + Profil Fotoğraflarını İndir + 📁 İndirme Klasörü + Kullanıcı Adı Alt Klasörüne Kaydet + Dosya Adına Zaman Damgası Ekle + Klasör seçici burada açılamıyor + Ayarları Yedekle + Ayarları Geri Yükle + Yedekleme oluşturulamadı: %1$s + GitHub + Telegram + ⚠️ Uygulama önbelleğini temizle ve yeniden başlat? + Şimdi Yeniden Başlat + Yeniden başlatmak için uygulama bulunamadı. + Yeniden başlatma başarısız: %1$s + + + Hayalet modu seçeneği seçilmedi! + Hayalet modu etkinleştirildi + Hayalet modu devre dışı bırakıldı + ✅ Görüldü gönderildi + ✅ Kanal görüldü gönderildi + + + sizi takip ediyor ✅ + sizi takip etmiyor ❌ + + + Ayarlar uygulandı! + Değişikliklerin geçerli olması için Instagram\'ı manuel olarak kapatın. + Ayarlar başarıyla geri yüklendi. + Geri yükleme başarısız: %1$s + + + İndirme klasörü: %1$s + İndirme klasörü güncellendi! + İndirme klasörü varsayılana sıfırlandı + + + İndiriliyor… + Video indiriliyor… + Fotoğraf indiriliyor… + Video InstaEclipse klasörüne kaydedildi + Fotoğraf InstaEclipse klasörüne kaydedildi + InstaEclipse klasörüne kaydedildi + İndirme başarısız: %1$s + Reel indiriliyor… + Reel InstaEclipse klasörüne kaydedildi + Reel indirme başarısız: %1$s + Reel URL\'si bulunamadı + Gönderi URL\'si bulunamadı + Bu gönderi için medya bulunamadı + Medya bulunamadı + Hikaye videosu indiriliyor… + Hikaye fotoğrafı indiriliyor… + Hikaye InstaEclipse klasörüne kaydedildi + Hikaye URL\'si bulunamadı + Profil fotoğrafı indiriliyor… + Profil fotoğrafı kaydedildi! + Profil fotoğrafı URL\'si alınamadı + Video ve ses birleştiriliyor… + %1$d öğe indiriliyor… + Tüm %1$d öğe indiriliyor… + Tüm %1$d öğe InstaEclipse klasörüne kaydedildi + %2$d öğeden %1$d\'si kaydedildi (%3$d başarısız) + Reel URL\'si bulunamadı — önce reeli kaydırın + İndir + Döngü • %1$d öğe + Mevcut olanı indir (%1$d/%2$d) + Tüm %1$d öğeyi indir + Bahsedilmeleri görüntüle + @%1$s kopyalandı + Tüm bahsedilmeler kopyalandı + + Geri + Değişiklikleri Uygula + + + Kategoriler: + Araçlar: + Özellikler: + Yapılandırma: + Seçenekler: + Hızlı Geçiş: + Tehlikeli Bölge: + İndirme Klasörü: + 📂 İndirme Klasörü Konumunu Ayarla + 📂 Seçilen: %1$s + 🗑 İndirme Klasörünü Sıfırla + + + ⚠️ Birden fazla sürüm tespit edildi + + + Hikaye Bahsedilmeleri + %1$d bahsedilme • kopyalamak için dokun + Bu hikayede bahsedilme bulunamadı + Tümünü Kopyala diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index cf32ff6b..3a0d1d10 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -2,11 +2,16 @@ InstaEclipse + v0.5 Beta 首页 功能 帮助 版本 + + 返回 + 应用更改 + ⚠️ 检测到多个版本 模块未启用。请启用它。 模块状态: 模块状态:已禁用 @@ -25,9 +30,11 @@ 长按搜索图标 贡献者 特别感谢 + Instagram Logo instagram_info + 帮助和故障排除 查询更新和文档。 @@ -35,41 +42,38 @@ 查询支持 前往 Telegram InstaEclipse Telegram Group + InstaEclipse GitHub GitHub Telegram + 常见问题 - -1. 使用 Google Play 版本时无法正常运行或崩溃? -\n- 请使用 APKMirror 下载的版本。 - - -2. 模块未启用? -\n- 在 LSPosed 中禁用并重新启用该模块。 - - -3. 功能无法使用? -\n- 强制停止并重新启动 Instagram。 - - -4. 开发者选项导致崩溃? -\n- 按照开发者选项指南操作。 - - -5. 开发者选项的标签或数字异常 -\n- 使用 Beta 或 Alpha 版本,因为稳定版有进行过混淆。 - - -6. 启用了无干扰模式,但内容仍然显示? -\n- 强制停止 Instagram 并清除其缓存。 - + 1. 使用 Google Play 版本时无法正常运行或崩溃?\n- 请使用 APKMirror 下载的版本。 + 2. 模块未启用?\n- 在 LSPosed 中禁用并重新启用该模块。 + 3. 功能无法使用?\n- 强制停止并重新启动 Instagram。 + 4. 开发者选项导致崩溃?\n- 按照开发者选项指南操作。 + 5. 开发者选项的标签或数字异常\n- 使用 Beta 或 Alpha 版本,因为稳定版有进行过混淆。 + 6. 启用了无干扰模式,但内容仍然显示?\n- 强制停止 Instagram 并清除其缓存。 + 模块仍然无法使用? - -获取 root 权限,请尝试使用 JingMatrix\'s LSPosed

。如果您未获取 root 权限,请安装JingMatrix\'s LSPatch,然后依据指南进行操作 ]]> -
+ 获取 root 权限,请尝试使用 JingMatrix\'s LSPosed

。如果您未获取 root 权限,请安装JingMatrix\'s LSPatch,然后依据指南进行操作 ]]>
+ + + 分类: + 工具: + 功能: + 配置: + 选项: + 快速切换: + 危险区域: + 下载文件夹: + 📂 设置下载文件夹位置 + 📂 已选择:%1$s + 🗑 重置下载文件夹 + 开发者选项 幽灵模式 @@ -77,8 +81,10 @@ 移除广告 移除分析 启用/禁用所有 + Hint + 幽灵模式选项 隐藏输入状态 @@ -88,6 +94,7 @@ 私信 停用输入状态提示 截图 + 开发者选项指南 仅能在 Beta/Alpha 中启用! @@ -96,10 +103,12 @@ 2. 导航至开发者选项 > MetaConfig 设定与覆盖。 3. 搜索「Employee」并启用以下选项: 4. 最后,从模块中禁用开发者选项以防止应用崩溃。 + • is employee • is employee or test user • employee options + 无干扰模式选项 关闭限时动态 @@ -107,6 +116,7 @@ 关闭连续短片 禁用探索 禁用评论 + 杂项 杂项选项 @@ -114,6 +124,223 @@ 禁用视频自动播放 显示关注者提示 显示已启用功能提示 + - Contributor Name -
+ 贡献者名称 + + + + + + + InstaEclipse 🌘 + 关闭 + 启用/禁用所有 + 错误 + 确定 + + 取消 + + + 🎛 开发者选项 + 👻 幽灵模式设置 + 🛡 屏蔽广告/分析 + 🧘 无干扰 Instagram + ⚙ 其他功能 + 📥 下载器 + 💾 备份 & 恢复 + ℹ️ 关于 + 🔁 重启应用 + + + 开发者选项 🎛 + 幽灵模式 👻 + 屏蔽广告/分析 🛡️ + 无干扰 Instagram 🧘 + 杂项 ⚙️ + 下载器 📥 + 下载器设置 ⚙️ + 备份 & 恢复 💾 + 关于 + 重启应用 + 自定义快速切换 🛠️ + + + 启用开发者模式 + 导入开发者配置 + 导出开发者配置 + 移除版本过期弹窗 + 无法打开 InstaEclipse 界面。 + Instagram 未打开或未就绪。 + 未找到 mc_overrides.json。 + 读取配置失败:%1$s + 未收到配置数据。 + 配置导出成功。 + 保存失败:%1$s + 无效的 URI + ✅ 配置已导入。 + ❌ 导入配置失败。 + + + 选择 JSON 配置文件 + ❌ 未指定目标包。 + ✅ 已发送至 Instagram。 + ❌ 非有效 JSON 文件。 + ❌ 读取文件失败:%1$s + 已取消或未选择文件。 + + + 隐藏私信已读状态 + 隐藏正在输入指示器 + 隐藏限时动态观看记录 + 隐藏直播在线状态 + 允许在私信中截图 + 绕过截图检测 + 隐藏查看一次已读状态 + 无限重播查看一次媒体 + ⚠️ 永久查看一次媒体 + 保留阅后即焚消息 + 🛠 自定义快速切换 + + + 包含隐藏私信已读状态 + 包含隐藏正在输入指示器 + 包含绕过截图检测 + 包含隐藏查看一次已读状态 + 包含隐藏限时动态观看记录 + 包含隐藏直播在线状态 + 包含保留阅后即焚消息 + 包含无限重播查看一次媒体 + 包含永久查看一次媒体 + 包含允许在私信中截图 + + + 屏蔽广告 + 屏蔽分析 + 禁用追踪链接 + + + 极端模式 🔒 (重新安装前不可逆) + 关闭限时动态 + 关闭动态 + 关闭连续短片 + 关闭连续短片(私信除外) + 禁用探索 + 禁用评论 + 启用极端模式? + 启用后,在重新安装应用之前无法禁用无干扰模式。是否继续? + + + 禁用限时动态自动切换 + 禁用视频自动播放 + 禁用转发 + 显示功能提示 + 显示关注者提示 + 查看限时动态提及 + 禁用发现好友 + 复制评论 + + + 复制评论 + 复制完整评论 + 选择要复制的部分 + 选择要复制的内容 + 复制所选内容 + ✅ 已复制到剪贴板! + + + ⚙ 下载器设置 + 下载帖子 + 下载限时动态 + 下载连续短片 + 下载个人资料图片 + 📁 下载文件夹 + 保存到用户名子文件夹 + 在文件名中添加时间戳 + 无法在此处打开文件夹选择器 + + + 备份设置 + 恢复设置 + 创建备份失败:%1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ 清除应用缓存并重启? + 立即重启 + 找不到要重启的应用。 + 重启失败:%1$s + + + 未选择幽灵模式选项! + 幽灵模式已启用 + 幽灵模式已禁用 + ✅ 已读已发送 + ✅ 频道已读已发送 + + + 正在关注您 ✅ + 未关注您 ❌ + + + 设置已应用! + 请手动关闭 Instagram 以使更改生效。 + 设置已成功恢复。 + 恢复失败:%1$s + + + 下载文件夹:%1$s + 下载文件夹已更新! + 下载文件夹已重置为默认值 + + + 正在下载… + 正在下载视频… + 正在下载图片… + 视频已保存到 InstaEclipse 文件夹 + 图片已保存到 InstaEclipse 文件夹 + 已保存到 InstaEclipse 文件夹 + 下载失败:%1$s + 正在下载连续短片… + 连续短片已保存到 InstaEclipse 文件夹 + 连续短片下载失败:%1$s + 未找到连续短片 URL + 未找到帖子 URL + 此帖子未找到媒体 + 未找到媒体 + 正在下载限时动态视频… + 正在下载限时动态图片… + 限时动态已保存到 InstaEclipse 文件夹 + 未找到限时动态 URL + 正在下载个人资料图片… + 个人资料图片已保存! + 无法获取个人资料图片 URL + 正在合并视频 + 音频… + 正在下载 %1$d 个项目… + 正在下载全部 %1$d 个项目… + 全部 %1$d 个项目已保存到 InstaEclipse 文件夹 + %1$d / %2$d 个项目已保存(%3$d 个失败) + 未找到连续短片 URL — 请先滚动查看连续短片 + + + 下载 + 轮播 • %1$d 个项目 + 下载当前(%1$d / %2$d) + 下载全部 %1$d 个项目 + + + 查看提及 + @%1$s 已复制 + 所有提及已复制 + + + 限时动态提及 + %1$d 个提及 • 点击复制 + 此限时动态中未找到提及 + 复制全部 + + \ No newline at end of file diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2bbdb1c2..7ff16df9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -119,4 +119,204 @@ Contributor Name + + 關閉 + 全部啟用/停用 + 錯誤 + 確定 + + 取消 + 🎛 開發者選項 + 👻 幽靈模式設定 + 🛡 廣告/分析封鎖 + 🧘 無干擾 Instagram + ⚙ 其他功能 + 📥 下載器 + 💾 備份與還原 + ℹ️ 關於 + 🔁 重新啟動應用程式 + 開發者選項 🎛 + 幽靈模式 👻 + 廣告/分析封鎖 🛡️ + 無干擾 Instagram 🧘 + 其他 ⚙️ + 下載器 📥 + 下載器設定 ⚙️ + 備份與還原 💾 + 關於 + 重新啟動應用程式 + 自訂快速切換 🛠️ + 啟用開發者模式 + 匯入開發者設定 + 匯出開發者設定 + 移除版本過期彈窗 + 無法開啟 InstaEclipse 介面。 + Instagram 未開啟或未就緒。 + 找不到 mc_overrides.json。 + 讀取設定失敗:%1$s + 未收到設定資料。 + 設定匯出成功。 + 儲存失敗:%1$s + 無效的 URI + ✅ 設定已匯入。 + ❌ 匯入設定失敗。 + + + 選擇 JSON 設定檔 + ❌ 未指定目標套件。 + ✅ 已傳送至 Instagram。 + ❌ 非有效的 JSON 檔案。 + ❌ 讀取檔案失敗:%1$s + 已取消或未選擇檔案。 + 隱藏私訊已讀 + 隱藏輸入狀態 + 隱藏限時動態觀看 + 隱藏直播在線 + 允許在私訊中截圖 + 繞過截圖偵測 + 隱藏限看一次開啟 + 無限重播限看一次 + ⚠️ 將限看媒體轉為永久 + 保留消失訊息 + 🛠 自訂快速切換 + 包含隱藏私訊已讀 + 包含隱藏輸入狀態 + 包含繞過截圖偵測 + 包含隱藏限看一次開啟 + 包含隱藏限時動態觀看 + 包含隱藏直播在線 + 包含保留消失訊息 + 包含無限重播限看一次 + 包含將限看媒體轉為永久 + 包含允許在私訊中截圖 + 封鎖廣告 + 封鎖分析 + 停用追蹤連結 + 極端模式 🔒(重新安裝前不可逆) + 停用限時動態 + 停用動態消息 + 停用 Reels + 停用 Reels(私訊除外) + 停用探索 + 停用留言 + 啟用極端模式? + 啟用後,在重新安裝應用程式前無法停用無干擾模式。繼續? + 停用限時動態自動滑動 + 停用影片自動播放 + 停用分享 + 顯示功能提示 + 顯示追蹤者提示 + 查看限時動態提及 + 停用探索用戶 + 複製留言 + + + 複製留言 + 複製完整留言 + 選取部分複製 + 選取後複製 + 複製選取內容 + ✅ 已複製到剪貼簿! + ⚙ 下載器設定 + 下載貼文 + 下載限時動態 + 下載 Reels + 下載大頭照 + 📁 下載資料夾 + 儲存在用戶名子資料夾 + 在檔名中加入時間戳 + 此處無法開啟資料夾選擇器 + 備份設定 + 還原設定 + 建立備份失敗:%1$s + GitHub + Telegram + ⚠️ 清除應用程式快取並重新啟動? + 立即重新啟動 + 找不到要重新啟動的應用程式。 + 重新啟動失敗:%1$s + + + 未選擇任何幽靈模式選項! + 幽靈模式已啟用 + 幽靈模式已停用 + ✅ 已讀已傳送 + ✅ 頻道已讀已傳送 + + + 正在追蹤您 ✅ + 未追蹤您 ❌ + + + 設定已套用! + 請手動關閉 Instagram 以使變更生效。 + 設定已成功還原。 + 還原失敗:%1$s + + + 下載資料夾:%1$s + 下載資料夾已更新! + 下載資料夾已重設為預設值 + + + 下載中… + 正在下載影片… + 正在下載相片… + 影片已儲存至 InstaEclipse 資料夾 + 相片已儲存至 InstaEclipse 資料夾 + 已儲存至 InstaEclipse 資料夾 + 下載失敗:%1$s + 正在下載 Reels… + Reels 已儲存至 InstaEclipse 資料夾 + Reels 下載失敗:%1$s + 找不到 Reels 連結 + 找不到貼文連結 + 找不到此貼文的媒體 + 找不到媒體 + 正在下載限時動態影片… + 正在下載限時動態相片… + 限時動態已儲存至 InstaEclipse 資料夾 + 找不到限時動態連結 + 正在下載大頭照… + 大頭照已儲存! + 無法取得大頭照連結 + 正在合併影片與音訊… + 正在下載 %1$d 個項目… + 正在下載全部 %1$d 個項目… + 全部 %1$d 個項目已儲存至 InstaEclipse 資料夾 + %2$d 個項目中已儲存 %1$d 個(%3$d 個失敗) + 找不到 Reels 連結 — 請先滑過 Reels + 下載 + 輪播 • %1$d 個項目 + 下載目前項目(%1$d / %2$d) + 下載全部 %1$d 個項目 + 查看提及 + 已複製 @%1$s + 已複製所有提及 + + 返回 + 套用變更 + + + 分類: + 工具: + 功能: + 設定: + 選項: + 快速切換: + 危險區域: + 下載資料夾: + 📂 設定下載資料夾位置 + 📂 已選擇:%1$s + 🗑 重設下載資料夾 + + + ⚠️ 偵測到多個版本 + + + 限時動態提及 + %1$d 個提及 • 點擊複製 + 此限時動態中未發現提及 + 全部複製 + \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index d3bc9963..7854d8fb 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -2,21 +2,5 @@ com.instagram.android - com.instagold.android - com.instaflux.app - com.myinsta.android - cc.honista.app - com.instaprime.android - com.instafel.android - com.instadm.android - com.dfistagram.android - com.Instander.android - com.aero.instagram - com.instapro.android - com.instaflow.android - com.instagram1.android - com.instagram2.android - com.instagramclone.android - com.instaclone.android \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 738c692b..2e82a8bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,13 +1,16 @@ InstaEclipse - v0.4.5 Beta + v0.5 Beta Home Features Help Version + Back + Apply Changes + ⚠️ Multiple versions detected Module is not enabled. Please enable it. Module Status: @@ -63,6 +66,19 @@ Module still isn\'t working? rooted try using JingMatrix\'s LSPosed

If you are not rooted install JingMatrix\'s LSPatch then follow this guide]]>
+ + Categories: + Tools: + Features: + Config: + Options: + Quick Toggle: + Danger Zone: + Download Folder: + 📂 Set Download Folder Location + 📂 Selected: %1$s + 🗑 Reset Download Folder + Developer Options Ghost Mode @@ -119,4 +135,219 @@ Contributor Name + + + + + + InstaEclipse 🌘 + Close + Enable/Disable All + Error + OK + Yes + Cancel + + + 🎛 Developer Options + 👻 Ghost Mode Settings + 🛡 Ad/Analytics Block + 🧘 Distraction-Free Instagram + ⚙ Misc Features + 📥 Downloader + 💾 Backup & Restore + ℹ️ About + 🔁 Restart App + + + Developer Options 🎛 + Ghost Mode 👻 + Ad/Analytics Block 🛡️ + Distraction-Free Instagram 🧘 + Miscellaneous ⚙️ + Downloader 📥 + Downloader Settings ⚙️ + Backup & Restore 💾 + About + Restart App + Customize Quick Toggle 🛠️ + + + Enable Developer Mode + Import Dev Config + Export Dev Config + Remove Build Expired Popup + Unable to open InstaEclipse UI. + Instagram is not open or ready. + mc_overrides.json not found. + Failed to read config: %1$s + No config data received. + Config exported successfully. + Failed to save: %1$s + Invalid URI + ✅ Config imported. + ❌ Failed to import config. + + + Select JSON Config + ❌ Target package not specified. + ✅ Sent to Instagram. + ❌ Not a valid JSON file. + ❌ Failed to read file: %1$s + Cancelled or no file selected. + + + Hide DM Seen + Hide Typing Indicator + Hide Story Views + Hide Live Presence + Allow Screenshots in DMs + Bypass Screenshot Detection + Hide View Once Opened + Unlimited View-Once Replays + ⚠️ Permanent View Once Media + Keep Disappearing Messages + 🛠 Customize Quick Toggle + + + Include Hide DM Seen + Include Hide Typing Indicator + Include Bypass Screenshot Detection + Include Hide View Once Opened + Include Hide Story Views + Include Hide Live Presence + Include Keep Disappearing Messages + Include Unlimited View-Once Replays + Include Permanent View Once Media + Include Allow Screenshots in DMs + + + Block Ads + Block Analytics + Disable Tracking Links + + + Extreme Mode 🔒 (Irreversible until reinstall) + Disable Stories + Disable Feed + Disable Reels + Disable Reels Except in DMs + Disable Explore + Disable Comments + Activate Extreme Mode? + Once activated, you cannot disable Distraction-Free Mode until you reinstall the app. Continue? + + + Disable Story Auto-Swipe + Disable Video Autoplay + Disable Repost + Show Feature Toasts + Show Follower Toast + View Story Mentions + Disable Discover People + Copy Comment + + + Copy Comment + Copy Full Comment + Select Part to Copy + Select to Copy + Copy Selected + ✅ Copied to clipboard! + + + ⚙ Downloader Settings + Download Posts + Download Stories + Download Reels + Download Profile Pictures + 📁 Download Folder +Save in Username Subfolder + Add Timestamp to Filename + Cannot open folder picker here + + + Backup Settings + Restore Settings + Failed to create backup: %1$s + + + Created by @reso7200 + GitHub + Telegram + + + ⚠️ Clear app cache and restart? + Restart Now + Could not find the app to restart. + Restart failed: %1$s + + + No Ghost Mode options selected! + Ghost Mode Enabled + Ghost Mode Disabled + ✅ Seen Sent + ✅ Channel Seen Sent + + + follows you ✅ + doesn\'t follow you ❌ + + + Settings Applied! + Please kill Instagram manually for changes to take effect. + Settings restored successfully. + Restore failed: %1$s + + + Download folder: %1$s + Download folder updated! + Download folder reset to Default + + + Downloading… + Downloading video… + Downloading photo… + Video saved to InstaEclipse folder + Photo saved to InstaEclipse folder + Saved to InstaEclipse folder + Download failed: %1$s + Downloading reel… + Reel saved to InstaEclipse folder + Reel download failed: %1$s + Reel URL not found + Post URL not found + No media found for this post + No media found + Downloading story video… + Downloading story photo… + Story saved to InstaEclipse folder + Story URL not found + Downloading profile picture… + Profile picture saved! + Could not get profile picture URL + Merging video + audio… + Downloading %1$d items… + Downloading all %1$d items… + All %1$d items saved to InstaEclipse folder + %1$d of %2$d items saved (%3$d failed) + No reel URL found — scroll the reel first + + + Download + Carousel • %1$d items + Download current (%1$d of %2$d) + Download all %1$d items + + + View Mentions + @%1$s copied + All mentions copied + + + Story Mentions + %1$d mention(s) • tap to copy + No mentions found in this story + Copy All +
diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index b2486633..aaf8371a 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -23,4 +23,33 @@ 1sp @android:color/white + + + + + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 400245b7..fef33af6 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,21 +1,11 @@ + + - diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 09523c0e..df97d72b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/version.json b/version.json index 9bf7ce2a..949ecf9a 100644 --- a/version.json +++ b/version.json @@ -1,4 +1,4 @@ { - "latest_version": "0.4.5", + "latest_version": "0.5.0", "update_url": "https://github.com/ReSo7200/InstaEclipse/releases/latest" }