From cede73160b7551f52ae58b38c3c4c9fadd5f0752 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:26:04 +0530 Subject: [PATCH 1/5] feat: rewarded ads thing so free user can also enjoy ad free --- src/lib/actionStack.js | 5 +- src/lib/adRewards.js | 417 ++++++++++++++++++ src/lib/remoteStorage.js | 10 +- src/lib/secureAdRewardState.js | 33 ++ src/lib/startAd.js | 3 + src/main.js | 3 + src/pages/adRewards/adRewards.scss | 249 +++++++++++ src/pages/adRewards/index.js | 188 ++++++++ src/pages/fileBrowser/fileBrowser.js | 4 - src/pages/plugin/plugin.js | 10 +- .../auth/src/android/Authenticator.java | 185 +++++++- src/settings/mainSettings.js | 10 + src/sidebarApps/extensions/index.js | 6 +- src/sidebarApps/searchInFiles/index.js | 8 +- src/utils/helpers.js | 16 +- 15 files changed, 1116 insertions(+), 31 deletions(-) create mode 100644 src/lib/adRewards.js create mode 100644 src/lib/secureAdRewardState.js create mode 100644 src/pages/adRewards/adRewards.scss create mode 100644 src/pages/adRewards/index.js diff --git a/src/lib/actionStack.js b/src/lib/actionStack.js index 03b92e2e8..915898056 100644 --- a/src/lib/actionStack.js +++ b/src/lib/actionStack.js @@ -1,5 +1,6 @@ import confirm from "dialogs/confirm"; import appSettings from "lib/settings"; +import helpers from "utils/helpers"; const stack = []; let mark = null; @@ -93,9 +94,7 @@ export default { } } - if (IS_FREE_VERSION && window.iad?.isLoaded()) { - window.iad.show(); - } + helpers.showInterstitialIfReady(); exitApp(); } diff --git a/src/lib/adRewards.js b/src/lib/adRewards.js new file mode 100644 index 000000000..96280cd60 --- /dev/null +++ b/src/lib/adRewards.js @@ -0,0 +1,417 @@ +import toast from "components/toast"; +import auth from "./auth"; +import secureAdRewardState from "./secureAdRewardState"; + +const ONE_HOUR = 60 * 60 * 1000; +const MAX_TIMEOUT = 2_147_483_647; + +const OFFERS = [ + { + id: "quick", + title: "Quick pass", + description: "Watch 1 rewarded ad and pause ads for 1 hour.", + adsRequired: 1, + durationMs: ONE_HOUR, + accentClass: "is-quick", + }, + { + id: "focus", + title: "Focus block", + description: + "Watch 2 rewarded ads and pause ads for a random 4, 5, or 6 hours.", + adsRequired: 2, + minDurationMs: 4 * ONE_HOUR, + maxDurationMs: 6 * ONE_HOUR, + accentClass: "is-focus", + }, +]; + +let state = getDefaultState(); +let expiryTimer = null; +let activeWatchPromise = null; +const listeners = new Set(); + +function getDefaultState() { + return { + adFreeUntil: 0, + lastExpiredRewardUntil: 0, + isActive: false, + remainingMs: 0, + redemptionsToday: 0, + remainingRedemptions: 3, + maxRedemptionsPerDay: 3, + maxActivePassMs: 10 * ONE_HOUR, + hasPendingExpiryNotice: false, + expiryNoticePendingUntil: 0, + canRedeem: true, + redeemDisabledReason: "", + }; +} + +function formatDuration(durationMs) { + const totalHours = Math.round(durationMs / ONE_HOUR); + if (totalHours < 1) return "less than 1 hour"; + if (totalHours === 1) return "1 hour"; + return `${totalHours} hours`; +} + +function formatDurationRange(minDurationMs, maxDurationMs) { + if (!minDurationMs || !maxDurationMs || minDurationMs === maxDurationMs) { + return formatDuration(minDurationMs || maxDurationMs || 0); + } + + const minHours = Math.round(minDurationMs / ONE_HOUR); + const maxHours = Math.round(maxDurationMs / ONE_HOUR); + return `${minHours}-${maxHours} hours`; +} + +function getRewardedUnitId() { + return window.adRewardedUnitId || ""; +} + +function getExpiryDate() { + return state.adFreeUntil ? new Date(state.adFreeUntil) : null; +} + +function emitChange() { + const snapshot = { + ...state, + expiryDate: getExpiryDate(), + }; + listeners.forEach((listener) => { + try { + listener(snapshot); + } catch (error) { + console.error("Reward state listener failed.", error); + } + }); +} + +function hideActiveBanner() { + if (window.ad?.active) { + window.ad.active = false; + window.ad.hide?.(); + } +} + +function notify(title, message, type = "info") { + toast(message, 4000); + window.acode?.pushNotification?.(title, message, { + icon: type === "success" ? "verified" : "notifications", + type, + }); +} + +function normalizeStatus(status) { + const fallback = getDefaultState(); + if (!status || typeof status !== "object") return fallback; + + const adFreeUntil = Number(status.adFreeUntil) || 0; + const remainingMs = Math.max(0, Number(status.remainingMs) || 0); + + return { + ...fallback, + ...status, + adFreeUntil, + lastExpiredRewardUntil: Number(status.lastExpiredRewardUntil) || 0, + remainingMs, + redemptionsToday: Number(status.redemptionsToday) || 0, + remainingRedemptions: Number(status.remainingRedemptions) || 0, + maxRedemptionsPerDay: + Number(status.maxRedemptionsPerDay) || fallback.maxRedemptionsPerDay, + maxActivePassMs: Number(status.maxActivePassMs) || fallback.maxActivePassMs, + expiryNoticePendingUntil: Number(status.expiryNoticePendingUntil) || 0, + isActive: Boolean(status.isActive && adFreeUntil > Date.now()), + hasPendingExpiryNotice: Boolean(status.hasPendingExpiryNotice), + canRedeem: Boolean(status.canRedeem), + redeemDisabledReason: String(status.redeemDisabledReason || ""), + }; +} + +function clearExpiryTimer() { + if (expiryTimer) { + clearTimeout(expiryTimer); + expiryTimer = null; + } +} + +async function refreshState({ notifyExpiry = false } = {}) { + try { + const nextState = normalizeStatus(await secureAdRewardState.getStatus()); + state = nextState; + emitChange(); + scheduleExpiryCheck(); + + if (notifyExpiry && nextState.hasPendingExpiryNotice) { + notify( + "Ad-free pass ended", + "Your rewarded ad-free time has expired. You can watch another rewarded ad anytime.", + "warning", + ); + } + + return nextState; + } catch (error) { + console.warn("Failed to refresh rewarded ad state.", error); + return state; + } +} + +function scheduleExpiryCheck() { + clearExpiryTimer(); + if (!state.adFreeUntil) return; + + const remainingMs = state.adFreeUntil - Date.now(); + if (remainingMs <= 0) { + void refreshState({ notifyExpiry: true }); + return; + } + + expiryTimer = setTimeout( + () => { + void refreshState({ notifyExpiry: true }); + }, + Math.min(remainingMs, MAX_TIMEOUT), + ); +} + +async function getRewardIdentity() { + try { + const user = await auth.getUserInfo(); + const userId = + user?.id || + user?._id || + user?.github || + user?.username || + device?.uuid || + "guest"; + return String(userId); + } catch (error) { + console.warn("Failed to resolve rewarded ad user identity.", error); + return String(device?.uuid || "guest"); + } +} + +async function createRewardedAd(offer, step, sessionId) { + const rewardedUnitId = getRewardedUnitId(); + if (!rewardedUnitId || !admob?.RewardedAd) { + throw new Error("Rewarded ads are not available in this build."); + } + + const userId = await getRewardIdentity(); + const customData = [ + `session=${sessionId}`, + `offer=${offer.id}`, + `step=${step}`, + `ads=${offer.adsRequired}`, + ].join("&"); + + return new admob.RewardedAd({ + adUnitId: rewardedUnitId, + serverSideVerification: { + userId, + customData, + }, + }); +} + +function waitForRewardedResult(ad) { + return new Promise((resolve, reject) => { + let earned = false; + let settled = false; + + const finish = (result) => { + if (settled) return; + settled = true; + resolve(result); + }; + + const fail = (error) => { + if (settled) return; + settled = true; + reject( + error instanceof Error + ? error + : new Error(error?.message || "Rewarded ad failed."), + ); + }; + + ad.on("reward", () => { + earned = true; + }); + + ad.on("dismiss", () => { + finish({ earned }); + }); + + ad.on("showfail", fail); + ad.on("loadfail", fail); + }); +} + +async function showRewardedStep(offer, step, sessionId) { + const rewardedAd = await createRewardedAd(offer, step, sessionId); + const resultPromise = waitForRewardedResult(rewardedAd); + await rewardedAd.load(); + await rewardedAd.show(); + const result = await resultPromise; + if (!result.earned) { + throw new Error("Reward not earned. The ad was closed before completion."); + } +} + +export default { + async init() { + await refreshState({ notifyExpiry: false }); + }, + onChange(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async handleResume() { + await refreshState({ notifyExpiry: true }); + }, + getState() { + return { + ...state, + expiryDate: getExpiryDate(), + }; + }, + getOffers() { + return OFFERS.map((offer) => ({ + ...offer, + durationLabel: formatDurationRange( + offer.minDurationMs || offer.durationMs, + offer.maxDurationMs || offer.durationMs, + ), + })); + }, + getRemainingMs() { + return Math.max(0, state.remainingMs || state.adFreeUntil - Date.now()); + }, + getRemainingLabel() { + const remainingMs = this.getRemainingMs(); + if (!remainingMs) return "No active ad-free pass"; + + const minutes = Math.ceil(remainingMs / (60 * 1000)); + if (minutes < 60) { + return `${minutes} minute${minutes === 1 ? "" : "s"} remaining`; + } + + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + if (!remMinutes) { + return `${hours} hour${hours === 1 ? "" : "s"} remaining`; + } + + return `${hours}h ${remMinutes}m remaining`; + }, + getExpiryLabel() { + const expiryDate = getExpiryDate(); + if (!expiryDate) return "No active pass"; + return expiryDate.toLocaleString(); + }, + isAdFreeActive() { + return Boolean(state.isActive && state.adFreeUntil > Date.now()); + }, + canShowAds() { + return Boolean(window.IS_FREE_VERSION && !this.isAdFreeActive()); + }, + isRewardedSupported() { + return Boolean( + window.IS_FREE_VERSION && admob?.RewardedAd && getRewardedUnitId(), + ); + }, + getRewardedUnavailableReason() { + if (!window.IS_FREE_VERSION) + return "Ads are already disabled on this build."; + if (!admob?.RewardedAd) + return "Rewarded ads are unavailable on this device."; + if (!getRewardedUnitId()) { + return "Rewarded ads are not configured for production yet."; + } + return ""; + }, + canRedeemNow() { + return { + ok: Boolean(state.canRedeem), + reason: state.redeemDisabledReason || "", + }; + }, + isWatchingReward() { + return Boolean(activeWatchPromise); + }, + async watchOffer(offerId, { onStep } = {}) { + if (activeWatchPromise) { + return activeWatchPromise; + } + + const offer = OFFERS.find((item) => item.id === offerId); + if (!offer) { + throw new Error("Reward offer not found."); + } + if (!this.isRewardedSupported()) { + throw new Error(this.getRewardedUnavailableReason()); + } + + await refreshState({ notifyExpiry: false }); + const redemptionStatus = this.canRedeemNow(); + if (!redemptionStatus.ok) { + throw new Error(redemptionStatus.reason); + } + + const sessionId = + typeof crypto?.randomUUID === "function" + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + + activeWatchPromise = (async () => { + for (let step = 1; step <= offer.adsRequired; step += 1) { + onStep?.({ + step, + totalSteps: offer.adsRequired, + offer, + }); + await showRewardedStep(offer, step, sessionId); + + if (step < offer.adsRequired) { + toast( + `Reward ${step}/${offer.adsRequired} complete. Loading the next ad...`, + 2500, + ); + } + } + + const redeemedState = normalizeStatus( + await secureAdRewardState.redeem(offer.id), + ); + const grantedDurationMs = + Number(redeemedState.appliedDurationMs) || + Number(redeemedState.grantedDurationMs) || + 0; + + state = redeemedState; + emitChange(); + hideActiveBanner(); + scheduleExpiryCheck(); + + notify( + "Ad-free pass started", + `${formatDuration(grantedDurationMs)} unlocked. Ads will stay hidden until ${new Date(redeemedState.adFreeUntil).toLocaleString()}.`, + "success", + ); + + return { + offer, + expiresAt: redeemedState.adFreeUntil, + grantedDurationMs, + }; + })().finally(() => { + activeWatchPromise = null; + emitChange(); + }); + + emitChange(); + return activeWatchPromise; + }, +}; diff --git a/src/lib/remoteStorage.js b/src/lib/remoteStorage.js index 883b8b88d..acfdd00d5 100644 --- a/src/lib/remoteStorage.js +++ b/src/lib/remoteStorage.js @@ -65,9 +65,7 @@ export default { res.home = home; } loader.destroy(); - if (IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); - } + await helpers.showInterstitialIfReady(); return res; } catch (err) { if (stopConnection) { @@ -232,9 +230,7 @@ export default { }, }); loader.destroy(); - if (IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); - } + await helpers.showInterstitialIfReady(); return { alias, name: alias, @@ -428,7 +424,7 @@ export default { }; async function loadAd() { - if (!IS_FREE_VERSION) return; + if (!helpers.canShowAds()) return; try { if (!(await window.iad?.isLoaded())) { toast(strings.loading); diff --git a/src/lib/secureAdRewardState.js b/src/lib/secureAdRewardState.js new file mode 100644 index 000000000..004d5471e --- /dev/null +++ b/src/lib/secureAdRewardState.js @@ -0,0 +1,33 @@ +function execAuthenticator(action, args = []) { + return new Promise((resolve, reject) => { + if (!window.cordova?.exec) { + reject(new Error("Cordova exec is unavailable.")); + return; + } + + cordova.exec(resolve, reject, "Authenticator", action, args); + }); +} + +export default { + async getStatus() { + try { + const raw = await execAuthenticator("getRewardStatus"); + if (!raw) return null; + return typeof raw === "string" ? JSON.parse(raw) : raw; + } catch (error) { + console.warn("Failed to load secure rewarded ad status.", error); + return null; + } + }, + async redeem(offerId) { + try { + const raw = await execAuthenticator("redeemReward", [offerId]); + if (!raw) return null; + return typeof raw === "string" ? JSON.parse(raw) : raw; + } catch (error) { + console.warn("Failed to redeem rewarded ad offer.", error); + throw error; + } + }, +}; diff --git a/src/lib/startAd.js b/src/lib/startAd.js index e4eccd9ef..009d3cd9e 100644 --- a/src/lib/startAd.js +++ b/src/lib/startAd.js @@ -1,5 +1,6 @@ let adUnitIdBanner = "ca-app-pub-5911839694379275/9157899592"; // Production let adUnitIdInterstitial = "ca-app-pub-5911839694379275/9570937608"; // Production +let adUnitIdRewarded = "ca-app-pub-5911839694379275/1633667633"; // Production let initialized = false; export default async function startAd() { @@ -11,6 +12,7 @@ export default async function startAd() { if (BuildInfo.type === "debug") { adUnitIdBanner = "ca-app-pub-3940256099942544/6300978111"; // Test adUnitIdInterstitial = "ca-app-pub-3940256099942544/5224354917"; // Test + adUnitIdRewarded = "ca-app-pub-3940256099942544/5224354917"; // Test } } @@ -53,4 +55,5 @@ export default async function startAd() { }); window.ad = banner; window.iad = interstitial; + window.adRewardedUnitId = adUnitIdRewarded; } diff --git a/src/main.js b/src/main.js index fc274767d..2918698f6 100644 --- a/src/main.js +++ b/src/main.js @@ -35,6 +35,7 @@ import quickToolsInit from "handlers/quickToolsInit"; import windowResize from "handlers/windowResize"; import Acode from "lib/acode"; import actionStack from "lib/actionStack"; +import adRewards from "lib/adRewards"; import applySettings from "lib/applySettings"; import checkFiles from "lib/checkFiles"; import checkPluginsUpdate from "lib/checkPluginsUpdate"; @@ -237,6 +238,7 @@ async function onDeviceReady() { return true; })(); window.acode = new Acode(); + await adRewards.init(); ensureAceCompatApi(); system.requestPermission("android.permission.READ_EXTERNAL_STORAGE"); @@ -814,6 +816,7 @@ function pauseHandler() { } function resumeHandler() { + adRewards.handleResume(); if (!settings.value.checkFiles) return; checkFiles(); } diff --git a/src/pages/adRewards/adRewards.scss b/src/pages/adRewards/adRewards.scss new file mode 100644 index 000000000..977d5c551 --- /dev/null +++ b/src/pages/adRewards/adRewards.scss @@ -0,0 +1,249 @@ +#ad-rewards-page { + padding: 16px; + max-width: 600px; + margin: 0 auto; + color: var(--primary-text-color); + + .reward-hero { + margin-bottom: 20px; + + .hero-copy { + margin-bottom: 16px; + + .eyebrow { + display: inline-flex; + align-items: center; + padding: 4px 10px; + border-radius: 6px; + background: color-mix(in srgb, + var(--active-color) 15%, + transparent); + color: var(--active-color); + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + } + + h1 { + margin: 10px 0 6px; + font-size: 1.2rem; + font-weight: 700; + line-height: 1.3; + } + + p { + margin: 0; + font-size: 0.85rem; + color: color-mix(in srgb, + var(--primary-text-color) 60%, + transparent); + line-height: 1.5; + } + } + + .reward-status { + padding: 12px 14px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + background: color-mix(in srgb, + var(--primary-color) 10%, + transparent); + border-radius: 10px; + border: 1px solid var(--border-color); + transition: all 0.2s ease; + + &.is-active { + border-color: color-mix(in srgb, + var(--active-color) 50%, + transparent); + background: color-mix(in srgb, + var(--active-color) 10%, + transparent); + } + + .status-label { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: color-mix(in srgb, + var(--primary-text-color) 55%, + transparent); + } + + .status-value { + font-size: 0.9rem; + font-weight: 700; + flex: 1; + min-width: 0; + } + + .status-note { + font-size: 0.78rem; + color: color-mix(in srgb, + var(--primary-text-color) 55%, + transparent); + width: 100%; + } + + .status-subnote { + font-size: 0.74rem; + color: color-mix(in srgb, + var(--primary-text-color) 50%, + transparent); + width: 100%; + } + } + } + + .reward-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + margin-bottom: 20px; + } + + .reward-offer { + background: color-mix(in srgb, + var(--popup-background-color) 20%, + transparent); + border-radius: 12px; + border: 1px solid var(--border-color); + padding: 14px; + display: flex; + flex-direction: column; + gap: 8px; + transition: border-color 0.2s ease; + + &.is-focus { + border-color: color-mix(in srgb, + var(--link-text-color) 50%, + transparent); + } + + &.is-upgrade { + border-color: color-mix(in srgb, + var(--button-background-color) 40%, + transparent); + } + + .offer-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + } + + .offer-kicker { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: color-mix(in srgb, + var(--primary-text-color) 55%, + transparent); + } + + h2 { + margin: 3px 0 0; + font-size: 0.88rem; + font-weight: 600; + } + + .offer-duration { + padding: 3px 8px; + border-radius: 6px; + background: var(--primary-color); + color: var(--primary-text-color); + font-size: 0.68rem; + font-weight: 600; + white-space: nowrap; + align-self: flex-start; + } + + p { + margin: 0; + color: color-mix(in srgb, + var(--primary-text-color) 60%, + transparent); + line-height: 1.45; + font-size: 0.8rem; + flex: 1; + } + + .offer-limit { + font-size: 0.74rem; + line-height: 1.4; + color: color-mix(in srgb, + var(--primary-text-color) 55%, + transparent); + } + } + + .offer-action { + appearance: none; + border: 0; + border-radius: 8px; + padding: 9px 12px; + font: inherit; + font-size: 0.82rem; + font-weight: 600; + background: var(--button-background-color); + color: var(--button-text-color); + cursor: pointer; + transition: all 0.2s ease; + + &:active { + transform: translateY(1px); + opacity: 0.9; + } + + &:disabled { + opacity: 0.45; + cursor: not-allowed; + } + + &.secondary { + background: var(--primary-color); + color: var(--primary-text-color); + } + } + + .reward-notes { + display: grid; + gap: 10px; + + .note-card { + padding: 12px 14px; + border-left: 3px solid var(--active-color); + border-radius: 0 8px 8px 0; + background: color-mix(in srgb, + var(--primary-color) 8%, + transparent); + + h3 { + margin: 0 0 4px; + font-size: 0.82rem; + font-weight: 600; + } + + p { + margin: 0; + line-height: 1.45; + font-size: 0.78rem; + color: color-mix(in srgb, + var(--primary-text-color) 60%, + transparent); + } + } + } +} + +@media (max-width: 400px) { + #ad-rewards-page .reward-grid { + grid-template-columns: 1fr; + } +} diff --git a/src/pages/adRewards/index.js b/src/pages/adRewards/index.js new file mode 100644 index 000000000..bef250728 --- /dev/null +++ b/src/pages/adRewards/index.js @@ -0,0 +1,188 @@ +import "./adRewards.scss"; + +import Page from "components/page"; +import loader from "dialogs/loader"; +import actionStack from "lib/actionStack"; +import adRewards from "lib/adRewards"; +import removeAds from "lib/removeAds"; +import helpers from "utils/helpers"; + +let $rewardPage = null; + +export default function openAdRewardsPage() { + if ($rewardPage) { + $rewardPage.show?.(); + return $rewardPage; + } + + const $page = Page("Ad-free passes"); + + function render() { + const rewardState = adRewards.getState(); + const rewardedSupported = adRewards.isRewardedSupported(); + const unavailableReason = adRewards.getRewardedUnavailableReason(); + const offers = adRewards.getOffers(); + const isBusy = adRewards.isWatchingReward(); + const redemptionStatus = adRewards.canRedeemNow(); + const rewardDisabledReason = !rewardedSupported + ? unavailableReason + : !redemptionStatus.ok + ? redemptionStatus.reason + : ""; + + $page.body = ( +
+
+
+
Rewarded ads
+

Trade a short ad break for focused coding time.

+

+ Unlock temporary ad-free time without leaving the free version. + When your pass expires, Acode will show a toast and add a + notification in-app. +

+
+
+
+ {rewardState.isActive ? "Ad-free active" : "No active pass"} +
+
+ {rewardState.isActive + ? adRewards.getRemainingLabel() + : "Watch a rewarded ad to start a pass"} +
+
+ {rewardState.isActive + ? `Expires ${adRewards.getExpiryLabel()}` + : "Passes stack on top of any active rewarded time."} +
+
+ {rewardState.redemptionsToday}/{rewardState.maxRedemptionsPerDay}{" "} + rewards used today +
+
+
+ +
+ {offers.map((offer) => ( +
+
+
+
+ {offer.adsRequired} rewarded ad + {offer.adsRequired > 1 ? "s" : ""} +
+

{offer.title}

+
+
{offer.durationLabel}
+
+

{offer.description}

+ +
+ {rewardDisabledReason || + `${rewardState.remainingRedemptions} of ${rewardState.maxRedemptionsPerDay} rewards left today`} +
+
+ ))} + +
+
+
+
Permanent option
+

Remove ads for good

+
+
One purchase
+
+

+ If you use Acode daily, Pro still gives the cleanest experience. +

+ +
+
+ +
+
+

How it works

+

+ Rewarded passes hide your banners and interstitials until the + timer ends. If you already have time left, new rewards extend the + expiry. +

+
+
+

Limits

+

+ You can redeem up to {rewardState.maxRedemptionsPerDay} rewards + per day, and your active ad-free pass is capped at 10 hours. +

+
+
+
+ ); + } + + async function purchaseRemoveAds() { + try { + loader.showTitleLoader(); + await removeAds(); + $page.hide(); + } catch (error) { + helpers.error(error); + } finally { + loader.removeTitleLoader(); + } + } + + async function watchOffer(offerId) { + try { + render(); + await adRewards.watchOffer(offerId); + } catch (error) { + helpers.error(error); + } finally { + render(); + } + } + + const unsubscribe = adRewards.onChange(() => { + if ($page.isConnected) { + render(); + } + }); + + $page.onhide = () => { + unsubscribe(); + actionStack.remove("ad-rewards"); + helpers.showAd(); + $rewardPage = null; + }; + + actionStack.push({ + id: "ad-rewards", + action: $page.hide, + }); + + helpers.hideAd(true); + render(); + app.append($page); + $rewardPage = $page; + + return $page; +} diff --git a/src/pages/fileBrowser/fileBrowser.js b/src/pages/fileBrowser/fileBrowser.js index a4e9ef95b..266df102b 100644 --- a/src/pages/fileBrowser/fileBrowser.js +++ b/src/pages/fileBrowser/fileBrowser.js @@ -187,10 +187,6 @@ function FileBrowserInclude(mode, info, doesOpenLast = true) { onclick() { $page.hide(); - if (IS_FREE_VERSION && window.iad?.isLoaded()) { - window.iad.show(); - } - resolve({ type: "folder", ...currentDir, diff --git a/src/pages/plugin/plugin.js b/src/pages/plugin/plugin.js index caa5b80c8..f4ad33bc8 100644 --- a/src/pages/plugin/plugin.js +++ b/src/pages/plugin/plugin.js @@ -205,8 +205,8 @@ export default async function PluginInclude( if (onInstall) onInstall(plugin); installed = true; update = false; - if (!plugin.price && IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); + if (!plugin.price) { + await helpers.showInterstitialIfReady(); } render(); } catch (err) { @@ -228,8 +228,8 @@ export default async function PluginInclude( if (onUninstall) onUninstall(plugin.id); installed = false; update = false; - if (!plugin.price && IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); + if (!plugin.price) { + await helpers.showInterstitialIfReady(); } render(); } catch (err) { @@ -473,7 +473,7 @@ export default async function PluginInclude( } async function loadAd(el) { - if (!IS_FREE_VERSION) return; + if (!helpers.canShowAds()) return; try { if (!(await window.iad?.isLoaded())) { const oldText = el.textContent; diff --git a/src/plugins/auth/src/android/Authenticator.java b/src/plugins/auth/src/android/Authenticator.java index a89fee05e..1b82da9e4 100644 --- a/src/plugins/auth/src/android/Authenticator.java +++ b/src/plugins/auth/src/android/Authenticator.java @@ -5,8 +5,13 @@ import org.apache.cordova.*; import org.json.JSONArray; import org.json.JSONException; +import org.json.JSONObject; import java.net.HttpURLConnection; import java.net.URL; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Random; import java.util.Scanner; public class Authenticator extends CordovaPlugin { @@ -14,12 +19,19 @@ public class Authenticator extends CordovaPlugin { private static final String TAG = "AcodeAuth"; private static final String PREFS_FILENAME = "acode_auth_secure"; private static final String KEY_TOKEN = "auth_token"; + private static final String ADS_PREFS_FILENAME = "ads"; + private static final String KEY_REWARD_STATE = "reward_state"; + private static final long ONE_HOUR_MS = 60L * 60L * 1000L; + private static final long MAX_ACTIVE_PASS_MS = 10L * ONE_HOUR_MS; + private static final int MAX_REDEMPTIONS_PER_DAY = 3; private EncryptedPreferenceManager prefManager; + private EncryptedPreferenceManager adsPrefManager; @Override protected void pluginInitialize() { Log.d(TAG, "Initializing Authenticator Plugin..."); this.prefManager = new EncryptedPreferenceManager(this.cordova.getContext(), PREFS_FILENAME); + this.adsPrefManager = new EncryptedPreferenceManager(this.cordova.getContext(), ADS_PREFS_FILENAME); } @Override @@ -42,6 +54,12 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo prefManager.setString(KEY_TOKEN, token); callbackContext.success(); return true; + case "getRewardStatus": + callbackContext.success(getRewardStatus()); + return true; + case "redeemReward": + callbackContext.success(redeemReward(args.getString(0))); + return true; default: Log.w(TAG, "Attempted to call unknown action: " + action); return false; @@ -110,6 +128,171 @@ private void getUserInfo(CallbackContext callbackContext) { }); } + private String getRewardStatus() throws JSONException { + JSONObject state = syncRewardState(loadRewardState()); + JSONObject status = buildRewardStatus(state); + + if (status.optBoolean("hasPendingExpiryNotice")) { + state.put("expiryNoticePendingUntil", 0); + } + + saveRewardState(state); + return status.toString(); + } + + private String redeemReward(String offerId) throws JSONException { + JSONObject state = syncRewardState(loadRewardState()); + int redemptionsToday = state.optInt("redemptionsToday", 0); + long now = System.currentTimeMillis(); + long adFreeUntil = state.optLong("adFreeUntil", 0); + long remainingMs = Math.max(0L, adFreeUntil - now); + + if (redemptionsToday >= MAX_REDEMPTIONS_PER_DAY) { + throw new JSONException( + "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day." + ); + } + + if (remainingMs >= MAX_ACTIVE_PASS_MS) { + throw new JSONException("You already have the maximum 10 hours of ad-free time active."); + } + + long grantedDurationMs = resolveRewardDuration(offerId); + long baseTime = Math.max(now, adFreeUntil); + long newAdFreeUntil = Math.min(baseTime + grantedDurationMs, now + MAX_ACTIVE_PASS_MS); + long appliedDurationMs = Math.max(0L, newAdFreeUntil - baseTime); + + state.put("adFreeUntil", newAdFreeUntil); + state.put("lastExpiredRewardUntil", 0); + state.put("expiryNoticePendingUntil", 0); + state.put("redemptionDay", getTodayKey()); + state.put("redemptionsToday", redemptionsToday + 1); + saveRewardState(state); + + JSONObject status = buildRewardStatus(state); + status.put("grantedDurationMs", grantedDurationMs); + status.put("appliedDurationMs", appliedDurationMs); + status.put("offerId", offerId); + return status.toString(); + } + + private JSONObject loadRewardState() { + String raw = adsPrefManager.getString(KEY_REWARD_STATE, ""); + if (raw == null || raw.isEmpty()) { + return defaultRewardState(); + } + + try { + JSONObject parsed = new JSONObject(raw); + return mergeRewardState(parsed); + } catch (JSONException error) { + Log.w(TAG, "Failed to parse reward state, resetting.", error); + return defaultRewardState(); + } + } + + private JSONObject defaultRewardState() { + JSONObject state = new JSONObject(); + try { + state.put("adFreeUntil", 0L); + state.put("lastExpiredRewardUntil", 0L); + state.put("expiryNoticePendingUntil", 0L); + state.put("redemptionDay", getTodayKey()); + state.put("redemptionsToday", 0); + } catch (JSONException ignored) { + // No-op; JSONObject puts for primitives should not fail in practice. + } + return state; + } + + private JSONObject mergeRewardState(JSONObject parsed) { + JSONObject state = defaultRewardState(); + try { + state.put("adFreeUntil", parsed.optLong("adFreeUntil", 0L)); + state.put("lastExpiredRewardUntil", parsed.optLong("lastExpiredRewardUntil", 0L)); + state.put("expiryNoticePendingUntil", parsed.optLong("expiryNoticePendingUntil", 0L)); + state.put("redemptionDay", parsed.optString("redemptionDay", getTodayKey())); + state.put("redemptionsToday", parsed.optInt("redemptionsToday", 0)); + } catch (JSONException ignored) { + // Ignore and keep defaults. + } + return state; + } + + private void saveRewardState(JSONObject state) { + adsPrefManager.setString(KEY_REWARD_STATE, state.toString()); + } + + private JSONObject syncRewardState(JSONObject state) throws JSONException { + String todayKey = getTodayKey(); + if (!todayKey.equals(state.optString("redemptionDay", todayKey))) { + state.put("redemptionDay", todayKey); + state.put("redemptionsToday", 0); + } + + long adFreeUntil = state.optLong("adFreeUntil", 0L); + long now = System.currentTimeMillis(); + if (adFreeUntil > 0L && adFreeUntil <= now) { + if (state.optLong("expiryNoticePendingUntil", 0L) != adFreeUntil) { + state.put("expiryNoticePendingUntil", adFreeUntil); + } + state.put("lastExpiredRewardUntil", adFreeUntil); + state.put("adFreeUntil", 0L); + } + + return state; + } + + private JSONObject buildRewardStatus(JSONObject state) throws JSONException { + long now = System.currentTimeMillis(); + long adFreeUntil = state.optLong("adFreeUntil", 0L); + int redemptionsToday = state.optInt("redemptionsToday", 0); + long remainingMs = Math.max(0L, adFreeUntil - now); + int remainingRedemptions = Math.max(0, MAX_REDEMPTIONS_PER_DAY - redemptionsToday); + + JSONObject status = new JSONObject(); + status.put("adFreeUntil", adFreeUntil); + status.put("lastExpiredRewardUntil", state.optLong("lastExpiredRewardUntil", 0L)); + status.put("isActive", adFreeUntil > now); + status.put("remainingMs", remainingMs); + status.put("redemptionsToday", redemptionsToday); + status.put("remainingRedemptions", remainingRedemptions); + status.put("maxRedemptionsPerDay", MAX_REDEMPTIONS_PER_DAY); + status.put("maxActivePassMs", MAX_ACTIVE_PASS_MS); + status.put("hasPendingExpiryNotice", state.optLong("expiryNoticePendingUntil", 0L) > 0L); + status.put("expiryNoticePendingUntil", state.optLong("expiryNoticePendingUntil", 0L)); + + boolean canRedeem = remainingRedemptions > 0 && remainingMs < MAX_ACTIVE_PASS_MS; + status.put("canRedeem", canRedeem); + status.put("redeemDisabledReason", getRedeemDisabledReason(remainingRedemptions, remainingMs)); + return status; + } + + private String getRedeemDisabledReason(int remainingRedemptions, long remainingMs) { + if (remainingRedemptions <= 0) { + return "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day."; + } + if (remainingMs >= MAX_ACTIVE_PASS_MS) { + return "You already have the maximum 10 hours of ad-free time active."; + } + return ""; + } + + private long resolveRewardDuration(String offerId) throws JSONException { + if ("quick".equals(offerId)) { + return ONE_HOUR_MS; + } + if ("focus".equals(offerId)) { + int selectedHours = 4 + new Random().nextInt(3); + return selectedHours * ONE_HOUR_MS; + } + throw new JSONException("Unknown reward offer."); + } + + private String getTodayKey() { + return new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date()); + } + private String validateToken(String token) { HttpURLConnection conn = null; try { @@ -145,4 +328,4 @@ private String validateToken(String token) { } -} \ No newline at end of file +} diff --git a/src/settings/mainSettings.js b/src/settings/mainSettings.js index 859e7cef9..b6467bbc3 100644 --- a/src/settings/mainSettings.js +++ b/src/settings/mainSettings.js @@ -6,6 +6,7 @@ import openFile from "lib/openFile"; import removeAds from "lib/removeAds"; import appSettings from "lib/settings"; import settings from "lib/settings"; +import openAdRewardsPage from "pages/adRewards"; import Changelog from "pages/changelog/changelog"; import plugins from "pages/plugins"; import Sponsors from "pages/sponsors"; @@ -113,6 +114,11 @@ export default function mainSettings() { ]; if (IS_FREE_VERSION) { + items.push({ + key: "adRewards", + text: "Earn ad-free time", + icon: "play_arrow", + }); items.push({ key: "removeads", text: strings["remove ads"], @@ -156,6 +162,10 @@ export default function mainSettings() { plugins(); break; + case "adRewards": + openAdRewardsPage(); + break; + case "formatter": formatterSettings(); break; diff --git a/src/sidebarApps/extensions/index.js b/src/sidebarApps/extensions/index.js index 0a396cf9d..83545cdcf 100644 --- a/src/sidebarApps/extensions/index.js +++ b/src/sidebarApps/extensions/index.js @@ -864,7 +864,7 @@ function ListItem({ icon, name, id, version, downloads, installed, source }) { } async function loadAd(el) { - if (!IS_FREE_VERSION) return; + if (!helpers.canShowAds()) return; try { if (!(await window.iad?.isLoaded())) { const oldText = el.textContent; @@ -906,9 +906,7 @@ async function uninstall(id) { } // Show Ad If Its Free Version, interstitial Ad(iad) is loaded. - if (IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); - } + await helpers.showInterstitialIfReady(); } catch (err) { helpers.error(err); } diff --git a/src/sidebarApps/searchInFiles/index.js b/src/sidebarApps/searchInFiles/index.js index bfda17e25..50ff696fc 100644 --- a/src/sidebarApps/searchInFiles/index.js +++ b/src/sidebarApps/searchInFiles/index.js @@ -320,9 +320,7 @@ async function onWorkerMessage(e) { break; } - if (IS_FREE_VERSION && (await window.iad?.isLoaded())) { - window.iad.show(); - } + await helpers.showInterstitialIfReady(); terminateWorker(false); replacing = false; @@ -337,8 +335,8 @@ async function onWorkerMessage(e) { } const showAd = results.length > 100; - if (IS_FREE_VERSION && showAd && (await window.iad?.isLoaded())) { - window.iad.show(); + if (showAd) { + await helpers.showInterstitialIfReady(); } if (!results.length) { diff --git a/src/utils/helpers.js b/src/utils/helpers.js index bd49b1e8e..a925bfabf 100644 --- a/src/utils/helpers.js +++ b/src/utils/helpers.js @@ -3,6 +3,7 @@ import ajax from "@deadlyjack/ajax"; import { getModeForPath as getCMModeForPath } from "cm/modelist"; import alert from "dialogs/alert"; import escapeStringRegexp from "escape-string-regexp"; +import adRewards from "lib/adRewards"; import constants from "lib/constants"; import path from "./Path"; import Uri from "./Uri"; @@ -287,12 +288,23 @@ export default { editorManager.onupdate("file-delete"); editorManager.emit("update", "file-delete"); }, + canShowAds() { + return Boolean(IS_FREE_VERSION && adRewards.canShowAds()); + }, + async showInterstitialIfReady() { + if (!this.canShowAds()) return false; + if (await window.iad?.isLoaded()) { + window.iad.show(); + return true; + } + return false; + }, /** * Displays ad on the current page */ showAd() { const { ad } = window; - if (IS_FREE_VERSION && innerHeight * devicePixelRatio > 600 && ad) { + if (this.canShowAds() && innerHeight * devicePixelRatio > 600 && ad) { const $page = tag.getAll("wc-page:not(#root)").pop(); if ($page) { ad.active = true; @@ -306,7 +318,7 @@ export default { */ hideAd(force = false) { const { ad } = window; - if (IS_FREE_VERSION && ad?.active) { + if (ad?.active) { const $pages = tag.getAll(".page-replacement"); const hide = $pages.length === 1; From 4872337a0eeea3e58760e1534eabc966b6248e57 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:51:10 +0530 Subject: [PATCH 2/5] remove stuffs from auth --- src/lib/secureAdRewardState.js | 8 +- .../auth/src/android/Authenticator.java | 183 ----------------- .../system/EncryptedPreferenceManager.java | 36 ++++ .../foxdebug/system/RewardPassManager.java | 188 ++++++++++++++++++ .../android/com/foxdebug/system/System.java | 8 + src/plugins/system/plugin.xml | 15 +- src/plugins/system/system.d.ts | 45 ++++- src/plugins/system/www/plugin.js | 20 +- 8 files changed, 294 insertions(+), 209 deletions(-) create mode 100644 src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java create mode 100644 src/plugins/system/android/com/foxdebug/system/RewardPassManager.java diff --git a/src/lib/secureAdRewardState.js b/src/lib/secureAdRewardState.js index 004d5471e..ef77d23dd 100644 --- a/src/lib/secureAdRewardState.js +++ b/src/lib/secureAdRewardState.js @@ -1,18 +1,18 @@ -function execAuthenticator(action, args = []) { +function execSystem(action, args = []) { return new Promise((resolve, reject) => { if (!window.cordova?.exec) { reject(new Error("Cordova exec is unavailable.")); return; } - cordova.exec(resolve, reject, "Authenticator", action, args); + cordova.exec(resolve, reject, "System", action, args); }); } export default { async getStatus() { try { - const raw = await execAuthenticator("getRewardStatus"); + const raw = await execSystem("getRewardStatus"); if (!raw) return null; return typeof raw === "string" ? JSON.parse(raw) : raw; } catch (error) { @@ -22,7 +22,7 @@ export default { }, async redeem(offerId) { try { - const raw = await execAuthenticator("redeemReward", [offerId]); + const raw = await execSystem("redeemReward", [offerId]); if (!raw) return null; return typeof raw === "string" ? JSON.parse(raw) : raw; } catch (error) { diff --git a/src/plugins/auth/src/android/Authenticator.java b/src/plugins/auth/src/android/Authenticator.java index 1b82da9e4..48bf3cc08 100644 --- a/src/plugins/auth/src/android/Authenticator.java +++ b/src/plugins/auth/src/android/Authenticator.java @@ -5,13 +5,8 @@ import org.apache.cordova.*; import org.json.JSONArray; import org.json.JSONException; -import org.json.JSONObject; import java.net.HttpURLConnection; import java.net.URL; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.Random; import java.util.Scanner; public class Authenticator extends CordovaPlugin { @@ -19,19 +14,12 @@ public class Authenticator extends CordovaPlugin { private static final String TAG = "AcodeAuth"; private static final String PREFS_FILENAME = "acode_auth_secure"; private static final String KEY_TOKEN = "auth_token"; - private static final String ADS_PREFS_FILENAME = "ads"; - private static final String KEY_REWARD_STATE = "reward_state"; - private static final long ONE_HOUR_MS = 60L * 60L * 1000L; - private static final long MAX_ACTIVE_PASS_MS = 10L * ONE_HOUR_MS; - private static final int MAX_REDEMPTIONS_PER_DAY = 3; private EncryptedPreferenceManager prefManager; - private EncryptedPreferenceManager adsPrefManager; @Override protected void pluginInitialize() { Log.d(TAG, "Initializing Authenticator Plugin..."); this.prefManager = new EncryptedPreferenceManager(this.cordova.getContext(), PREFS_FILENAME); - this.adsPrefManager = new EncryptedPreferenceManager(this.cordova.getContext(), ADS_PREFS_FILENAME); } @Override @@ -54,12 +42,6 @@ public boolean execute(String action, JSONArray args, CallbackContext callbackCo prefManager.setString(KEY_TOKEN, token); callbackContext.success(); return true; - case "getRewardStatus": - callbackContext.success(getRewardStatus()); - return true; - case "redeemReward": - callbackContext.success(redeemReward(args.getString(0))); - return true; default: Log.w(TAG, "Attempted to call unknown action: " + action); return false; @@ -128,171 +110,6 @@ private void getUserInfo(CallbackContext callbackContext) { }); } - private String getRewardStatus() throws JSONException { - JSONObject state = syncRewardState(loadRewardState()); - JSONObject status = buildRewardStatus(state); - - if (status.optBoolean("hasPendingExpiryNotice")) { - state.put("expiryNoticePendingUntil", 0); - } - - saveRewardState(state); - return status.toString(); - } - - private String redeemReward(String offerId) throws JSONException { - JSONObject state = syncRewardState(loadRewardState()); - int redemptionsToday = state.optInt("redemptionsToday", 0); - long now = System.currentTimeMillis(); - long adFreeUntil = state.optLong("adFreeUntil", 0); - long remainingMs = Math.max(0L, adFreeUntil - now); - - if (redemptionsToday >= MAX_REDEMPTIONS_PER_DAY) { - throw new JSONException( - "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day." - ); - } - - if (remainingMs >= MAX_ACTIVE_PASS_MS) { - throw new JSONException("You already have the maximum 10 hours of ad-free time active."); - } - - long grantedDurationMs = resolveRewardDuration(offerId); - long baseTime = Math.max(now, adFreeUntil); - long newAdFreeUntil = Math.min(baseTime + grantedDurationMs, now + MAX_ACTIVE_PASS_MS); - long appliedDurationMs = Math.max(0L, newAdFreeUntil - baseTime); - - state.put("adFreeUntil", newAdFreeUntil); - state.put("lastExpiredRewardUntil", 0); - state.put("expiryNoticePendingUntil", 0); - state.put("redemptionDay", getTodayKey()); - state.put("redemptionsToday", redemptionsToday + 1); - saveRewardState(state); - - JSONObject status = buildRewardStatus(state); - status.put("grantedDurationMs", grantedDurationMs); - status.put("appliedDurationMs", appliedDurationMs); - status.put("offerId", offerId); - return status.toString(); - } - - private JSONObject loadRewardState() { - String raw = adsPrefManager.getString(KEY_REWARD_STATE, ""); - if (raw == null || raw.isEmpty()) { - return defaultRewardState(); - } - - try { - JSONObject parsed = new JSONObject(raw); - return mergeRewardState(parsed); - } catch (JSONException error) { - Log.w(TAG, "Failed to parse reward state, resetting.", error); - return defaultRewardState(); - } - } - - private JSONObject defaultRewardState() { - JSONObject state = new JSONObject(); - try { - state.put("adFreeUntil", 0L); - state.put("lastExpiredRewardUntil", 0L); - state.put("expiryNoticePendingUntil", 0L); - state.put("redemptionDay", getTodayKey()); - state.put("redemptionsToday", 0); - } catch (JSONException ignored) { - // No-op; JSONObject puts for primitives should not fail in practice. - } - return state; - } - - private JSONObject mergeRewardState(JSONObject parsed) { - JSONObject state = defaultRewardState(); - try { - state.put("adFreeUntil", parsed.optLong("adFreeUntil", 0L)); - state.put("lastExpiredRewardUntil", parsed.optLong("lastExpiredRewardUntil", 0L)); - state.put("expiryNoticePendingUntil", parsed.optLong("expiryNoticePendingUntil", 0L)); - state.put("redemptionDay", parsed.optString("redemptionDay", getTodayKey())); - state.put("redemptionsToday", parsed.optInt("redemptionsToday", 0)); - } catch (JSONException ignored) { - // Ignore and keep defaults. - } - return state; - } - - private void saveRewardState(JSONObject state) { - adsPrefManager.setString(KEY_REWARD_STATE, state.toString()); - } - - private JSONObject syncRewardState(JSONObject state) throws JSONException { - String todayKey = getTodayKey(); - if (!todayKey.equals(state.optString("redemptionDay", todayKey))) { - state.put("redemptionDay", todayKey); - state.put("redemptionsToday", 0); - } - - long adFreeUntil = state.optLong("adFreeUntil", 0L); - long now = System.currentTimeMillis(); - if (adFreeUntil > 0L && adFreeUntil <= now) { - if (state.optLong("expiryNoticePendingUntil", 0L) != adFreeUntil) { - state.put("expiryNoticePendingUntil", adFreeUntil); - } - state.put("lastExpiredRewardUntil", adFreeUntil); - state.put("adFreeUntil", 0L); - } - - return state; - } - - private JSONObject buildRewardStatus(JSONObject state) throws JSONException { - long now = System.currentTimeMillis(); - long adFreeUntil = state.optLong("adFreeUntil", 0L); - int redemptionsToday = state.optInt("redemptionsToday", 0); - long remainingMs = Math.max(0L, adFreeUntil - now); - int remainingRedemptions = Math.max(0, MAX_REDEMPTIONS_PER_DAY - redemptionsToday); - - JSONObject status = new JSONObject(); - status.put("adFreeUntil", adFreeUntil); - status.put("lastExpiredRewardUntil", state.optLong("lastExpiredRewardUntil", 0L)); - status.put("isActive", adFreeUntil > now); - status.put("remainingMs", remainingMs); - status.put("redemptionsToday", redemptionsToday); - status.put("remainingRedemptions", remainingRedemptions); - status.put("maxRedemptionsPerDay", MAX_REDEMPTIONS_PER_DAY); - status.put("maxActivePassMs", MAX_ACTIVE_PASS_MS); - status.put("hasPendingExpiryNotice", state.optLong("expiryNoticePendingUntil", 0L) > 0L); - status.put("expiryNoticePendingUntil", state.optLong("expiryNoticePendingUntil", 0L)); - - boolean canRedeem = remainingRedemptions > 0 && remainingMs < MAX_ACTIVE_PASS_MS; - status.put("canRedeem", canRedeem); - status.put("redeemDisabledReason", getRedeemDisabledReason(remainingRedemptions, remainingMs)); - return status; - } - - private String getRedeemDisabledReason(int remainingRedemptions, long remainingMs) { - if (remainingRedemptions <= 0) { - return "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day."; - } - if (remainingMs >= MAX_ACTIVE_PASS_MS) { - return "You already have the maximum 10 hours of ad-free time active."; - } - return ""; - } - - private long resolveRewardDuration(String offerId) throws JSONException { - if ("quick".equals(offerId)) { - return ONE_HOUR_MS; - } - if ("focus".equals(offerId)) { - int selectedHours = 4 + new Random().nextInt(3); - return selectedHours * ONE_HOUR_MS; - } - throw new JSONException("Unknown reward offer."); - } - - private String getTodayKey() { - return new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date()); - } - private String validateToken(String token) { HttpURLConnection conn = null; try { diff --git a/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java b/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java new file mode 100644 index 000000000..06c20f2f5 --- /dev/null +++ b/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java @@ -0,0 +1,36 @@ +package com.foxdebug.system; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKeys; +import java.io.IOException; +import java.security.GeneralSecurityException; + +public class EncryptedPreferenceManager { + private SharedPreferences sharedPreferences; + + public EncryptedPreferenceManager(Context context, String prefName) { + try { + String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); + + sharedPreferences = EncryptedSharedPreferences.create( + prefName, + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ); + } catch (GeneralSecurityException | IOException e) { + sharedPreferences = context.getSharedPreferences(prefName, Context.MODE_PRIVATE); + } + } + + public void setString(String key, String value) { + sharedPreferences.edit().putString(key, value).apply(); + } + + public String getString(String key, String defaultValue) { + return sharedPreferences.getString(key, defaultValue); + } +} diff --git a/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java b/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java new file mode 100644 index 000000000..fd9e8852e --- /dev/null +++ b/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java @@ -0,0 +1,188 @@ +package com.foxdebug.system; + +import android.content.Context; +import android.util.Log; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Random; +import org.json.JSONException; +import org.json.JSONObject; + +public class RewardPassManager { + private static final String TAG = "SystemRewardPass"; + private static final String ADS_PREFS_FILENAME = "ads"; + private static final String KEY_REWARD_STATE = "reward_state"; + private static final long ONE_HOUR_MS = 60L * 60L * 1000L; + private static final long MAX_ACTIVE_PASS_MS = 10L * ONE_HOUR_MS; + private static final int MAX_REDEMPTIONS_PER_DAY = 3; + + private final EncryptedPreferenceManager adsPrefManager; + private final Random random = new Random(); + + public RewardPassManager(Context context) { + this.adsPrefManager = new EncryptedPreferenceManager(context, ADS_PREFS_FILENAME); + } + + public String getRewardStatus() throws JSONException { + JSONObject state = syncRewardState(loadRewardState()); + JSONObject status = buildRewardStatus(state); + + if (status.optBoolean("hasPendingExpiryNotice")) { + state.put("expiryNoticePendingUntil", 0L); + } + + saveRewardState(state); + return status.toString(); + } + + public String redeemReward(String offerId) throws JSONException { + JSONObject state = syncRewardState(loadRewardState()); + int redemptionsToday = state.optInt("redemptionsToday", 0); + long now = java.lang.System.currentTimeMillis(); + long adFreeUntil = state.optLong("adFreeUntil", 0L); + long remainingMs = Math.max(0L, adFreeUntil - now); + + if (redemptionsToday >= MAX_REDEMPTIONS_PER_DAY) { + throw new JSONException( + "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day." + ); + } + + if (remainingMs >= MAX_ACTIVE_PASS_MS) { + throw new JSONException("You already have the maximum 10 hours of ad-free time active."); + } + + long grantedDurationMs = resolveRewardDuration(offerId); + long baseTime = Math.max(now, adFreeUntil); + long newAdFreeUntil = Math.min(baseTime + grantedDurationMs, now + MAX_ACTIVE_PASS_MS); + long appliedDurationMs = Math.max(0L, newAdFreeUntil - baseTime); + + state.put("adFreeUntil", newAdFreeUntil); + state.put("lastExpiredRewardUntil", 0L); + state.put("expiryNoticePendingUntil", 0L); + state.put("redemptionDay", getTodayKey()); + state.put("redemptionsToday", redemptionsToday + 1); + saveRewardState(state); + + JSONObject status = buildRewardStatus(state); + status.put("grantedDurationMs", grantedDurationMs); + status.put("appliedDurationMs", appliedDurationMs); + status.put("offerId", offerId); + return status.toString(); + } + + private JSONObject loadRewardState() { + String raw = adsPrefManager.getString(KEY_REWARD_STATE, ""); + if (raw == null || raw.isEmpty()) { + return defaultRewardState(); + } + + try { + return mergeRewardState(new JSONObject(raw)); + } catch (JSONException error) { + Log.w(TAG, "Failed to parse reward state, resetting.", error); + return defaultRewardState(); + } + } + + private JSONObject defaultRewardState() { + JSONObject state = new JSONObject(); + try { + state.put("adFreeUntil", 0L); + state.put("lastExpiredRewardUntil", 0L); + state.put("expiryNoticePendingUntil", 0L); + state.put("redemptionDay", getTodayKey()); + state.put("redemptionsToday", 0); + } catch (JSONException ignored) { + } + return state; + } + + private JSONObject mergeRewardState(JSONObject parsed) { + JSONObject state = defaultRewardState(); + try { + state.put("adFreeUntil", parsed.optLong("adFreeUntil", 0L)); + state.put("lastExpiredRewardUntil", parsed.optLong("lastExpiredRewardUntil", 0L)); + state.put("expiryNoticePendingUntil", parsed.optLong("expiryNoticePendingUntil", 0L)); + state.put("redemptionDay", parsed.optString("redemptionDay", getTodayKey())); + state.put("redemptionsToday", parsed.optInt("redemptionsToday", 0)); + } catch (JSONException ignored) { + } + return state; + } + + private void saveRewardState(JSONObject state) { + adsPrefManager.setString(KEY_REWARD_STATE, state.toString()); + } + + private JSONObject syncRewardState(JSONObject state) throws JSONException { + String todayKey = getTodayKey(); + if (!todayKey.equals(state.optString("redemptionDay", todayKey))) { + state.put("redemptionDay", todayKey); + state.put("redemptionsToday", 0); + } + + long adFreeUntil = state.optLong("adFreeUntil", 0L); + long now = java.lang.System.currentTimeMillis(); + if (adFreeUntil > 0L && adFreeUntil <= now) { + if (state.optLong("expiryNoticePendingUntil", 0L) != adFreeUntil) { + state.put("expiryNoticePendingUntil", adFreeUntil); + } + state.put("lastExpiredRewardUntil", adFreeUntil); + state.put("adFreeUntil", 0L); + } + + return state; + } + + private JSONObject buildRewardStatus(JSONObject state) throws JSONException { + long now = java.lang.System.currentTimeMillis(); + long adFreeUntil = state.optLong("adFreeUntil", 0L); + int redemptionsToday = state.optInt("redemptionsToday", 0); + long remainingMs = Math.max(0L, adFreeUntil - now); + int remainingRedemptions = Math.max(0, MAX_REDEMPTIONS_PER_DAY - redemptionsToday); + + JSONObject status = new JSONObject(); + status.put("adFreeUntil", adFreeUntil); + status.put("lastExpiredRewardUntil", state.optLong("lastExpiredRewardUntil", 0L)); + status.put("isActive", adFreeUntil > now); + status.put("remainingMs", remainingMs); + status.put("redemptionsToday", redemptionsToday); + status.put("remainingRedemptions", remainingRedemptions); + status.put("maxRedemptionsPerDay", MAX_REDEMPTIONS_PER_DAY); + status.put("maxActivePassMs", MAX_ACTIVE_PASS_MS); + status.put("hasPendingExpiryNotice", state.optLong("expiryNoticePendingUntil", 0L) > 0L); + status.put("expiryNoticePendingUntil", state.optLong("expiryNoticePendingUntil", 0L)); + + boolean canRedeem = remainingRedemptions > 0 && remainingMs < MAX_ACTIVE_PASS_MS; + status.put("canRedeem", canRedeem); + status.put("redeemDisabledReason", getRedeemDisabledReason(remainingRedemptions, remainingMs)); + return status; + } + + private String getRedeemDisabledReason(int remainingRedemptions, long remainingMs) { + if (remainingRedemptions <= 0) { + return "Daily limit reached. You can redeem up to " + MAX_REDEMPTIONS_PER_DAY + " rewards per day."; + } + if (remainingMs >= MAX_ACTIVE_PASS_MS) { + return "You already have the maximum 10 hours of ad-free time active."; + } + return ""; + } + + private long resolveRewardDuration(String offerId) throws JSONException { + if ("quick".equals(offerId)) { + return ONE_HOUR_MS; + } + if ("focus".equals(offerId)) { + int selectedHours = 4 + random.nextInt(3); + return selectedHours * ONE_HOUR_MS; + } + throw new JSONException("Unknown reward offer."); + } + + private String getTodayKey() { + return new SimpleDateFormat("yyyy-MM-dd", Locale.US).format(new Date()); + } +} diff --git a/src/plugins/system/android/com/foxdebug/system/System.java b/src/plugins/system/android/com/foxdebug/system/System.java index 37259c9ea..69edbffb8 100644 --- a/src/plugins/system/android/com/foxdebug/system/System.java +++ b/src/plugins/system/android/com/foxdebug/system/System.java @@ -121,12 +121,14 @@ public class System extends CordovaPlugin { private CallbackContext intentHandler; private CordovaWebView webView; private String fileProviderAuthority; + private RewardPassManager rewardPassManager; public void initialize(CordovaInterface cordova, CordovaWebView webView) { super.initialize(cordova, webView); this.context = cordova.getContext(); this.activity = cordova.getActivity(); this.webView = webView; + this.rewardPassManager = new RewardPassManager(this.context); this.activity.runOnUiThread( new Runnable() { @Override @@ -258,6 +260,12 @@ public void run() { case "getFilesDir": callbackContext.success(getFilesDir()); return true; + case "getRewardStatus": + callbackContext.success(rewardPassManager.getRewardStatus()); + return true; + case "redeemReward": + callbackContext.success(rewardPassManager.redeemReward(args.getString(0))); + return true; case "getParentPath": callbackContext.success(getParentPath(args.getString(0))); diff --git a/src/plugins/system/plugin.xml b/src/plugins/system/plugin.xml index 2ddfb25a1..83912f2d8 100644 --- a/src/plugins/system/plugin.xml +++ b/src/plugins/system/plugin.xml @@ -37,9 +37,12 @@ - - - - - - \ No newline at end of file + + + + + + + + + diff --git a/src/plugins/system/system.d.ts b/src/plugins/system/system.d.ts index a93ce26ad..81b4cb20a 100644 --- a/src/plugins/system/system.d.ts +++ b/src/plugins/system/system.d.ts @@ -27,15 +27,33 @@ interface FileShortcut { uri: string; } -interface Intent { - action: string; - data: string; - type: string; +interface Intent { + action: string; + data: string; + type: string; package: string; extras: { [key: string]: any; }; -} +} + +interface RewardStatus { + adFreeUntil: number; + lastExpiredRewardUntil: number; + isActive: boolean; + remainingMs: number; + redemptionsToday: number; + remainingRedemptions: number; + maxRedemptionsPerDay: number; + maxActivePassMs: number; + hasPendingExpiryNotice: boolean; + expiryNoticePendingUntil: number; + canRedeem: boolean; + redeemDisabledReason: string; + grantedDurationMs?: number; + appliedDurationMs?: number; + offerId?: string; +} type FileAction = 'VIEW' | 'EDIT' | 'SEND' | 'RUN'; type OnFail = (err: string) => void; @@ -254,10 +272,19 @@ interface System { * @param onSuccess * @param onFail */ - getCordovaIntent(onSuccess: (intent: Intent) => void, onFail: OnFail): void; - /** - * Enable/disable native WebView long-press context behavior. - * Use this when rendering a custom editor context menu. + getCordovaIntent(onSuccess: (intent: Intent) => void, onFail: OnFail): void; + getRewardStatus( + onSuccess: (status: RewardStatus | string) => void, + onFail: OnFail, + ): void; + redeemReward( + offerId: string, + onSuccess: (status: RewardStatus | string) => void, + onFail: OnFail, + ): void; + /** + * Enable/disable native WebView long-press context behavior. + * Use this when rendering a custom editor context menu. * @param disabled * @param onSuccess * @param onFail diff --git a/src/plugins/system/www/plugin.js b/src/plugins/system/www/plugin.js index c6d6f49ce..01d77ecb3 100644 --- a/src/plugins/system/www/plugin.js +++ b/src/plugins/system/www/plugin.js @@ -33,13 +33,19 @@ module.exports = { cordova.exec(success, error, 'System', 'getNativeLibraryPath', []); }, - getFilesDir: function (success, error) { - cordova.exec(success, error, 'System', 'getFilesDir', []); - }, - - getParentPath: function (path, success, error) { - cordova.exec(success, error, 'System', 'getParentPath', [path]); - }, + getFilesDir: function (success, error) { + cordova.exec(success, error, 'System', 'getFilesDir', []); + }, + getRewardStatus: function (success, error) { + cordova.exec(success, error, 'System', 'getRewardStatus', []); + }, + redeemReward: function (offerId, success, error) { + cordova.exec(success, error, 'System', 'redeemReward', [offerId]); + }, + + getParentPath: function (path, success, error) { + cordova.exec(success, error, 'System', 'getParentPath', [path]); + }, listChildren: function (path, success, error) { cordova.exec(success, error, 'System', 'listChildren', [path]); From d01b6dc5dd7c407d90fbff9d0e9b86517bc332a0 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:55:41 +0530 Subject: [PATCH 3/5] fix --- src/lib/adRewards.js | 10 ++++++++++ src/lib/startAd.js | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/lib/adRewards.js b/src/lib/adRewards.js index 96280cd60..4df7b0892 100644 --- a/src/lib/adRewards.js +++ b/src/lib/adRewards.js @@ -4,6 +4,7 @@ import secureAdRewardState from "./secureAdRewardState"; const ONE_HOUR = 60 * 60 * 1000; const MAX_TIMEOUT = 2_147_483_647; +const REWARDED_RESULT_TIMEOUT_MS = 90 * 1000; const OFFERS = [ { @@ -219,16 +220,25 @@ function waitForRewardedResult(ad) { return new Promise((resolve, reject) => { let earned = false; let settled = false; + const timeoutId = setTimeout(() => { + fail( + new Error( + "Rewarded ad timed out before completion. Please try again.", + ), + ); + }, REWARDED_RESULT_TIMEOUT_MS); const finish = (result) => { if (settled) return; settled = true; + clearTimeout(timeoutId); resolve(result); }; const fail = (error) => { if (settled) return; settled = true; + clearTimeout(timeoutId); reject( error instanceof Error ? error diff --git a/src/lib/startAd.js b/src/lib/startAd.js index 009d3cd9e..0cba1011d 100644 --- a/src/lib/startAd.js +++ b/src/lib/startAd.js @@ -11,7 +11,7 @@ export default async function startAd() { if (BuildInfo.type === "debug") { adUnitIdBanner = "ca-app-pub-3940256099942544/6300978111"; // Test - adUnitIdInterstitial = "ca-app-pub-3940256099942544/5224354917"; // Test + adUnitIdInterstitial = "ca-app-pub-3940256099942544/1033173712"; // Test adUnitIdRewarded = "ca-app-pub-3940256099942544/5224354917"; // Test } } From f06d43eb7814bc1d68b3ca21e31b2d69aee65b57 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:56:32 +0530 Subject: [PATCH 4/5] fix --- src/lib/adRewards.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/adRewards.js b/src/lib/adRewards.js index 4df7b0892..0907b3a6b 100644 --- a/src/lib/adRewards.js +++ b/src/lib/adRewards.js @@ -222,9 +222,7 @@ function waitForRewardedResult(ad) { let settled = false; const timeoutId = setTimeout(() => { fail( - new Error( - "Rewarded ad timed out before completion. Please try again.", - ), + new Error("Rewarded ad timed out before completion. Please try again."), ); }, REWARDED_RESULT_TIMEOUT_MS); From 755f805078581c1f510c427ab5a992da7008c877 Mon Sep 17 00:00:00 2001 From: Raunak Raj <71929976+bajrangCoder@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:32:34 +0530 Subject: [PATCH 5/5] fix --- .../system/EncryptedPreferenceManager.java | 36 ------------------- .../foxdebug/system/RewardPassManager.java | 1 + src/plugins/system/plugin.xml | 2 -- 3 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java diff --git a/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java b/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java deleted file mode 100644 index 06c20f2f5..000000000 --- a/src/plugins/system/android/com/foxdebug/system/EncryptedPreferenceManager.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.foxdebug.system; - -import android.content.Context; -import android.content.SharedPreferences; -import androidx.security.crypto.EncryptedSharedPreferences; -import androidx.security.crypto.MasterKeys; -import java.io.IOException; -import java.security.GeneralSecurityException; - -public class EncryptedPreferenceManager { - private SharedPreferences sharedPreferences; - - public EncryptedPreferenceManager(Context context, String prefName) { - try { - String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC); - - sharedPreferences = EncryptedSharedPreferences.create( - prefName, - masterKeyAlias, - context, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ); - } catch (GeneralSecurityException | IOException e) { - sharedPreferences = context.getSharedPreferences(prefName, Context.MODE_PRIVATE); - } - } - - public void setString(String key, String value) { - sharedPreferences.edit().putString(key, value).apply(); - } - - public String getString(String key, String defaultValue) { - return sharedPreferences.getString(key, defaultValue); - } -} diff --git a/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java b/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java index fd9e8852e..d947cf7ac 100644 --- a/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java +++ b/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java @@ -2,6 +2,7 @@ import android.content.Context; import android.util.Log; +import com.foxdebug.acode.rk.auth.EncryptedPreferenceManager; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; diff --git a/src/plugins/system/plugin.xml b/src/plugins/system/plugin.xml index 83912f2d8..cca8cce88 100644 --- a/src/plugins/system/plugin.xml +++ b/src/plugins/system/plugin.xml @@ -41,8 +41,6 @@ - -