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..0907b3a6b --- /dev/null +++ b/src/lib/adRewards.js @@ -0,0 +1,425 @@ +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 REWARDED_RESULT_TIMEOUT_MS = 90 * 1000; + +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 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 + : 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..ef77d23dd --- /dev/null +++ b/src/lib/secureAdRewardState.js @@ -0,0 +1,33 @@ +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, "System", action, args); + }); +} + +export default { + async getStatus() { + try { + const raw = await execSystem("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 execSystem("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..0cba1011d 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() { @@ -10,7 +11,8 @@ 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 } } @@ -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..48bf3cc08 100644 --- a/src/plugins/auth/src/android/Authenticator.java +++ b/src/plugins/auth/src/android/Authenticator.java @@ -145,4 +145,4 @@ private String validateToken(String token) { } -} \ No newline at end of file +} 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..d947cf7ac --- /dev/null +++ b/src/plugins/system/android/com/foxdebug/system/RewardPassManager.java @@ -0,0 +1,189 @@ +package com.foxdebug.system; + +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; +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..cca8cce88 100644 --- a/src/plugins/system/plugin.xml +++ b/src/plugins/system/plugin.xml @@ -37,9 +37,10 @@ - - - - - - \ 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]); 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;