From 9b00435d7267a54875760bd7ea4a093724aeee4e Mon Sep 17 00:00:00 2001 From: Ujwal223 Date: Fri, 16 Oct 2026 10:00:35 +0545 Subject: [PATCH] Port(82% Done): # Problem Fixes - > "Download Media" feature works completely for all media types (Posts, Stories, Reels) > Fixed a bug due to which file Picker wasn't opening > Fixed Performance bottlenecks > Fixed "Block Reels Swiping in DMs" not working properly and more... # New - > Lock App in potrait mode. > New dowload animations and UI. > Bottom nav bar has got that PREMIUM look now. and few more... # Known Problems: > Hide Likes Count doesnt work. there can be more bugs as v3 is incomplete. NOW COMES TOTALLY NEW FEATURES IN NEXT COMMITS ;) --- Ported/app/build.gradle.kts | 3 + Ported/app/src/main/AndroidManifest.xml | 42 +- .../assets/extensions/ublock/background.js | 19 +- .../main/assets/extensions/ublock/content.js | 42 +- .../extensions/ublock/focusgram_content.js | 827 +++++++------- .../assets/extensions/ublock/manifest.json | 2 +- .../main/assets/scripts/autoplay_blocker.js | 13 + .../src/main/assets/scripts/content_hider.js | 30 +- .../main/assets/scripts/fetch_interceptor.js | 177 ++- .../main/assets/scripts/media_downloader.js | 1008 +++++++++++++++++ .../src/main/assets/scripts/native_feel.js | 110 +- .../com/ujwal/focusgram/MainActivity.kt | 2 + .../core/datastore/SettingsRepository.kt | 5 +- .../focusgram/core/engine/ExtensionManager.kt | 41 +- .../engine/FocusGramNavigationDelegate.kt | 18 + .../engine/FocusGramPermissionDelegate.kt | 21 +- .../core/engine/FocusGramPromptDelegate.kt | 11 +- .../core/engine/GeckoSessionManager.kt | 42 +- .../core/engine/UserScriptManager.kt | 4 +- .../focusgram/core/model/FocusSettings.kt | 3 +- .../ui/components/CupertinoWheelPicker.kt | 40 +- .../focusgram/core/utils/HapticHelper.kt | 173 +++ .../core/utils/MediaDownloadHelper.kt | 385 ++++++- .../feature/navigation/NativeBottomNavBar.kt | 262 ++++- .../feature/screentime/ScreenTimeService.kt | 64 +- .../feature/session/SessionExhaustedScreen.kt | 10 + .../feature/settings/SettingsScreen.kt | 313 +++-- .../feature/wellness/AdsterraAdScreen.kt | 13 +- .../feature/wellness/BreathGateScreen.kt | 4 + .../feature/wellness/CooldownGateScreen.kt | 4 + .../com/ujwal/focusgram/ui/FocusGramApp.kt | 337 +++++- .../src/main/res/drawable/ic_notification.xml | 9 + Ported/app/src/main/res/xml/file_paths.xml | 5 + .../FocusFeaturesEnhancementsTest.kt | 292 +++++ .../com/ujwal/focusgram/FocusSettingsTest.kt | 2 + Ported/gradle/libs.versions.toml | 2 + 36 files changed, 3603 insertions(+), 732 deletions(-) create mode 100644 Ported/app/src/main/assets/scripts/media_downloader.js create mode 100644 Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt create mode 100644 Ported/app/src/main/res/drawable/ic_notification.xml create mode 100644 Ported/app/src/main/res/xml/file_paths.xml diff --git a/Ported/app/build.gradle.kts b/Ported/app/build.gradle.kts index 648cdac..5eea104 100644 --- a/Ported/app/build.gradle.kts +++ b/Ported/app/build.gradle.kts @@ -126,6 +126,7 @@ dependencies { // Security & Biometric implementation(libs.androidx.security.crypto) implementation(libs.androidx.biometric) + implementation(libs.androidx.fragment.ktx) // Coroutines implementation(libs.kotlinx.coroutines.core) @@ -158,6 +159,8 @@ configurations.all { "androidx.lifecycle:lifecycle-common-java8:2.8.7", "androidx.core:core:1.15.0", "androidx.core:core-ktx:1.15.0", + "androidx.fragment:fragment:1.8.6", + "androidx.fragment:fragment-ktx:1.8.6", "org.jetbrains.kotlin:kotlin-stdlib:2.0.21", "org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21", "org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.21", diff --git a/Ported/app/src/main/AndroidManifest.xml b/Ported/app/src/main/AndroidManifest.xml index ab0dfb2..e8c5cfa 100644 --- a/Ported/app/src/main/AndroidManifest.xml +++ b/Ported/app/src/main/AndroidManifest.xml @@ -3,18 +3,15 @@ - - - @@ -24,6 +21,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -80,6 +105,17 @@ android:value="FocusGram Digital Wellness Session & Screen Time Tracker" /> + + + + + diff --git a/Ported/app/src/main/assets/extensions/ublock/background.js b/Ported/app/src/main/assets/extensions/ublock/background.js index 40e32cc..49165af 100644 --- a/Ported/app/src/main/assets/extensions/ublock/background.js +++ b/Ported/app/src/main/assets/extensions/ublock/background.js @@ -26,9 +26,11 @@ let cachedSettings = { ghostHideDirectSeen: true, ghostHideTypingIndicator: true, unblurMode: 'tap', - videoDownloadEnabled: true + videoDownloadEnabled: false }; +let userIsAuthenticated = false; + const BLOCKED_URL_PATTERNS = [ "*://*.instagram.com/logging_client_events*", "*://*.instagram.com/ajax/bz*", @@ -66,8 +68,15 @@ const GHOST_BLOCKED_URL_PATTERNS = [ if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.onBeforeRequest) { browser.webRequest.onBeforeRequest.addListener( function (details) { - if (cachedSettings.noAds === false) { - // User turned ad blocker off: do not cancel requests + if (cachedSettings.noAds === false || !userIsAuthenticated) { + // User turned ad blocker off or user is not logged in: do not cancel requests + return { cancel: false }; + } + const url = (details.url || '').toLowerCase(); + // NEVER block anything on or related to authentication, login, signup, or security checks + if (url.includes('/accounts/') || url.includes('/login') || url.includes('/signup') || + url.includes('facebook.net') || url.includes('facebook.com') || url.includes('/ajax/bz') || + url.includes('/api/v1/web/accounts/') || url.includes('/api/v1/accounts/')) { return { cancel: false }; } console.log('[Content Blocker] Intercepted and blocked network ad request:', details.url); @@ -157,6 +166,10 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa browser.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message) return; + if (message.action === 'authStateChanged') { + userIsAuthenticated = !!message.isAuthenticated; + } + if (message.action === 'getSettings') { sendResponse({ settings: cachedSettings }); return true; diff --git a/Ported/app/src/main/assets/extensions/ublock/content.js b/Ported/app/src/main/assets/extensions/ublock/content.js index 83444b4..cbeb70b 100644 --- a/Ported/app/src/main/assets/extensions/ublock/content.js +++ b/Ported/app/src/main/assets/extensions/ublock/content.js @@ -11,23 +11,40 @@ const COSMETIC_STYLE_ID = 'focusgram-cosmetic-filters'; let adBlockingEnabled = true; + function isAuthPage() { + try { + const path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) { + return true; + } + const cookies = document.cookie || ''; + const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id='); + if ((path === '/' || path === '') && !hasAuthCookie) { + return true; + } + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) { + return true; + } + } catch (_) {} + return false; + } + + if (isAuthPage()) { + const existing = document.getElementById(COSMETIC_STYLE_ID); + if (existing) existing.remove(); + return; // NEVER run on auth, login, or signup pages + } + const CSS_RULES = ` - /* Hide sponsored feed articles and containers without breaking Infinite Scroll IntersectionObserver */ + /* Hide sponsored feed articles without breaking Infinite Scroll IntersectionObserver or feed containers */ div[data-ad-preview], div[data-testid="post_ad"], - div[aria-label*="Sponsored" i]:not(main):not([role="feed"]), - div[aria-label*="sponsored" i]:not(main):not([role="feed"]), - div[aria-label="Ad" i]:not(main):not([role="feed"]), - article:has([aria-label="Ad" i]), - article:has([aria-label*="Sponsored" i]), article:has(a[href*="/ads/"]), - article:has(a[href*="/ad_"]), article:has(a[href*="ads.instagram.com"]), article[data-ad-filtered="true"] { - display: none !important; - height: 0px !important; - min-height: 0px !important; - max-height: 0px !important; + height: 1px !important; + min-height: 1px !important; + max-height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; @@ -71,7 +88,7 @@ ]; const STRICT_AD_CTAS = [ - 'shop now', 'install now', 'sign up', 'apply now', + 'shop now', 'install now', 'apply now', 'book now', 'order now', 'get offer' ]; @@ -127,7 +144,6 @@ article.style.setProperty('margin', '0', 'important'); article.style.setProperty('padding', '0', 'important'); article.dataset.adFiltered = 'true'; - window.dispatchEvent(new Event('scroll')); } } diff --git a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js index 7903d91..1e8ea06 100644 --- a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js +++ b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js @@ -15,6 +15,53 @@ 'use strict'; (function () { + // Senior Blueprint: Direct content script communication with WebExtension background script + function postToNative(message) { + if (!message) return; + try { + if (typeof browser !== 'undefined' && browser.runtime) { + if (browser.runtime.sendNativeMessage) { + browser.runtime.sendNativeMessage("focusgram", message).catch(() => {}); + } + if (browser.runtime.sendMessage) { + browser.runtime.sendMessage(message).catch(() => {}); + } + } + } catch (_) {} + } + + function isAuthPage() { + try { + const path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) { + return true; + } + const cookies = document.cookie || ''; + const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id='); + if ((path === '/' || path === '') && !hasAuthCookie) { + return true; + } + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) { + return true; + } + } catch (_) {} + return false; + } + + if (isAuthPage()) { + postToNative({ action: 'authStateChanged', isAuthenticated: false, isAuthPage: true }); + // Watch for navigation/login completion without running any scripts or CSS + let authCheckInterval = setInterval(() => { + if (!isAuthPage()) { + clearInterval(authCheckInterval); + window.location.reload(); + } + }, 1000); + return; // Leave login/signup completely untouched! + } else { + postToNative({ action: 'authStateChanged', isAuthenticated: true, isAuthPage: false }); + } + console.log('[FocusGram] In-page controller initialized.'); // --- Immediate anti-flash style injected at document_start --- @@ -25,10 +72,9 @@ [data-fg-hide], [data-fg-hide="1"], div[data-focusgram-hidden-bottom-bar="true"], - nav:has(a[href*="/explore/"]), - div:has(> div > a[href="/"]):has(a[href*="/explore/"]), - div:has(> a[href="/"]):has(a[href*="/explore/"]), - footer:has(a[href*="/explore/"]) { + nav[role="navigation"]:has(a[href*="/explore/"]), + div[role="navigation"]:has(a[href*="/explore/"]), + footer[role="contentinfo"]:has(a[href*="/explore/"]) { display: none !important; visibility: hidden !important; height: 0 !important; @@ -55,7 +101,7 @@ hideFollowerCounts: false, hideSuggestedPosts: false, hideShopTab: false, - videoDownloadEnabled: true, + videoDownloadEnabled: false, ghostModeEnabled: false, ghostHideStorySeen: true, ghostHideDirectSeen: true, @@ -98,7 +144,7 @@ const AD_KEYWORDS = [ 'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung', - 'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags' + 'anuncio', 'werbeanzeige', 'publicité' ]; function textHasAdSignal(value) { @@ -110,69 +156,55 @@ function isAdNode(node) { if (!node || typeof node !== 'object') return false; const typename = String(node.__typename || ''); - const adText = JSON.stringify({ - organic_tracking_token: node.organic_tracking_token, - sponsor_tags: node.sponsor_tags, - social_context: node.social_context, - title: node.title, - header: node.header, - label: node.label, - overlay_text: node.overlay_text - }); + if (node.is_ad || node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) { + return true; + } + if (node.ad_id || node.ad_action_link || node.ad_impression_token || node.ad_metadata) { + return true; + } + if (node.commerciality_status === 'commercial' || (node.product_type && node.product_type === 'ad')) { + return true; + } + if (node.ad_header_style && node.ad_header_style !== 'none') { + return true; + } + if (typename === 'GraphAdStory' || typename.includes('Ad')) { + return true; + } - return !!( - node.is_ad || - node.is_paid_partnership || - (node.sponsor_tags && node.sponsor_tags.length > 0) || - node.ad_tracking_token || - node.ad_action_link || - node.ad_id || - node.ad_impression_token || - node.ad_metadata || - node.commerciality_status === 'commercial' || - (node.product_type && node.product_type === 'ad') || - (node.ad_header_style && node.ad_header_style !== 'none') || - typename === 'GraphAdStory' || - typename.includes('Ad') || - textHasAdSignal(adText) - ); + const values = [node.social_context, node.title, node.header, node.label, node.overlay_text] + .filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' '); + + return textHasAdSignal(values); } function isSponsoredNode(node) { if (!node || typeof node !== 'object') return false; - return !!( - node.is_paid_partnership || - (node.sponsor_tags && node.sponsor_tags.length > 0) || - (node.ad_action_link && node.ad_action_link.href) || - (node.ad_header_style && node.ad_header_style !== 'none') || - textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || '')) - ); + if (node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) { + return true; + } + if (node.ad_action_link && node.ad_action_link.href) { + return true; + } + if (node.ad_header_style && node.ad_header_style !== 'none') { + return true; + } + const text = [node.social_context, node.header, node.label].filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' '); + return textHasAdSignal(text); } function isSuggestedNode(node) { if (!node || typeof node !== 'object') return false; const typename = String(node.__typename || ''); - const reason = JSON.stringify({ - reason: node.suggested_reason, - social_context: node.social_context, - title: node.title, - header: node.header, - label: node.label - }).toLowerCase(); - - return !!( - node.is_suggested || - node.is_suggested_for_you || - node.is_recommendation || - node.suggested_users || - node.suggested_media || - node.suggested_content || - node.recommendation_source || - typename.includes('Suggested') || - typename.includes('Recommendation') || - reason.includes('suggested') || - reason.includes('recommend') - ); + if (node.is_suggested || node.is_suggested_for_you || node.is_recommendation || + node.suggested_users || node.suggested_media || node.suggested_content || + node.recommendation_source || typename.includes('Suggested') || typename.includes('Recommendation') + ) { + return true; + } + const text = [node.suggested_reason, node.social_context, node.title, node.header, node.label] + .filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ').toLowerCase(); + return text.includes('suggested') || text.includes('recommend'); } function shouldFilterNode(node) { @@ -198,8 +230,25 @@ }); } + window.__fg_media_harvest = window.__fg_media_harvest || {}; + window.__fg_recent_media = window.__fg_recent_media || []; + window.__fg_profile_pics = window.__fg_profile_pics || {}; + function harvestReelFromObj(node) { if (!node || typeof node !== 'object') return; + + // Harvest profile pictures + if (node.user && typeof node.user === 'object' && node.user.username) { + const uName = String(node.user.username).toLowerCase(); + const pic = node.user.profile_pic_url_hd || node.user.profile_pic_url; + if (pic) window.__fg_profile_pics[uName] = pic; + } + if (node.owner && typeof node.owner === 'object' && node.owner.username) { + const uName = String(node.owner.username).toLowerCase(); + const pic = node.owner.profile_pic_url_hd || node.owner.profile_pic_url; + if (pic) window.__fg_profile_pics[uName] = pic; + } + const code = node.code || node.shortcode; if (typeof code === 'string' && code.length >= 4 && !['reels', 'reel', 'audio', 'videos', 'clips'].includes(code.toLowerCase())) { const author = node.user?.username || node.owner?.username || ''; @@ -211,24 +260,61 @@ } else if (node.edge_media_to_caption?.edges?.[0]?.node?.text) { caption = node.edge_media_to_caption.edges[0].node.text; } - if (author || caption) { - const metaStr = JSON.stringify({ code, author, caption }); - try { - let cacheEl = document.getElementById('fg-meta-cache'); - if (!cacheEl) { - cacheEl = document.createElement('div'); - cacheEl.id = 'fg-meta-cache'; - cacheEl.style.display = 'none'; - (document.body || document.documentElement).appendChild(cacheEl); - } - cacheEl.setAttribute('data-reel-' + code, metaStr); - } catch (_) {} - try { - document.dispatchEvent(new CustomEvent('focusgram:reel-meta', { - detail: metaStr - })); - } catch (_) {} + + // Harvest direct media URLs + const isVideo = !!(node.is_video || (node.video_versions && node.video_versions.length > 0) || node.video_url); + const videoUrl = (node.video_versions && node.video_versions[0]?.url) || node.video_url || ''; + const imageUrl = (node.image_versions2?.candidates && node.image_versions2.candidates[0]?.url) || node.display_url || ''; + + const carousel = []; + if (Array.isArray(node.carousel_media)) { + for (let c = 0; c < node.carousel_media.length; c++) { + const cItem = node.carousel_media[c]; + const cIsVideo = !!(cItem.is_video || (cItem.video_versions && cItem.video_versions.length > 0)); + const cVideoUrl = (cItem.video_versions && cItem.video_versions[0]?.url) || ''; + const cImageUrl = (cItem.image_versions2?.candidates && cItem.image_versions2.candidates[0]?.url) || ''; + carousel.push({ + url: cIsVideo && cVideoUrl ? cVideoUrl : cImageUrl, + videoUrl: cVideoUrl, + imageUrl: cImageUrl, + isVideo: cIsVideo && !!cVideoUrl + }); + } } + + const entry = { + code: code, + videoUrl: videoUrl, + imageUrl: imageUrl, + url: isVideo && videoUrl ? videoUrl : (imageUrl || videoUrl), + isVideo: isVideo && !!videoUrl, + carousel: carousel, + author: author, + caption: caption + }; + + window.__fg_media_harvest[code] = entry; + window.__fg_recent_media.unshift(entry); + if (window.__fg_recent_media.length > 100) { + window.__fg_recent_media.pop(); + } + + const metaStr = JSON.stringify(entry); + try { + let cacheEl = document.getElementById('fg-meta-cache'); + if (!cacheEl) { + cacheEl = document.createElement('div'); + cacheEl.id = 'fg-meta-cache'; + cacheEl.style.display = 'none'; + (document.body || document.documentElement).appendChild(cacheEl); + } + cacheEl.setAttribute('data-reel-' + code, metaStr); + } catch (_) {} + try { + document.dispatchEvent(new CustomEvent('focusgram:reel-meta', { + detail: metaStr + })); + } catch (_) {} } } @@ -480,10 +566,14 @@ const data = await cloned.json(); walkAndFilter(data); if (window.__fgFilterConfig && window.__fgFilterConfig.blockAds !== false) { + const newHeaders = new Headers(response.headers); + newHeaders.delete('content-length'); + newHeaders.delete('content-encoding'); + newHeaders.set('content-type', 'application/json; charset=utf-8'); return new Response(JSON.stringify(data), { status: response.status, statusText: response.statusText, - headers: response.headers + headers: newHeaders }); } } @@ -903,23 +993,6 @@ text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85) !important; } - - /* Feed Reveal Guardrail: Blurred posts on home feed */ - html[data-fg-feed] article:not([data-fg-reveal]) img, - html[data-fg-feed] article:not([data-fg-reveal]) video, - html[data-fg-feed] article:not([data-fg-reveal]) div._aagv, - html[data-fg-feed] article:not([data-fg-reveal]) div[role="presentation"] img, - html[data-fg-feed] article:not([data-fg-reveal]) div[role="presentation"] video { - filter: blur(28px) !important; - transition: filter 0.25s ease !important; - } - - html[data-fg-feed] article[data-fg-reveal] img, - html[data-fg-feed] article[data-fg-reveal] video, - html[data-fg-feed] article[data-fg-reveal] div._aagv, - html[data-fg-feed] article[data-fg-reveal] div[role="presentation"] img, - html[data-fg-feed] article[data-fg-reveal] div[role="presentation"] video { - filter: none !important; } /* Ensure comments sheet / input composer sits flush above keyboard without artificial gap */ @@ -1043,9 +1116,7 @@ div:has(> span > svg[aria-label="Unlike"]) + div span, /* In Reels overlay: the count text right below the like heart icon */ div:has(> svg[aria-label="Like"]) + div, - div:has(> svg[aria-label="Unlike"]) + div, - div:has(> svg[aria-label="Like"]) ~ div, - div:has(> svg[aria-label="Unlike"]) ~ div { + div:has(> svg[aria-label="Unlike"]) + div { display: none !important; } `; @@ -2330,30 +2401,175 @@ const FG_ACT_ATTR = 'data-fg-x-act'; const FG_ACT_CLASS = 'fg-x-act-btn'; + const IG_POST_ROOT_QUERY_DOC_ID = '27852811784380813'; - function requestMediaFromPage(code) { - return new Promise((resolve) => { - if (!code) return resolve(null); - const reqId = 'req_' + Math.random().toString(36).substring(2, 9); - const timer = setTimeout(() => { - window.removeEventListener('fg-response-media-' + reqId, onResponse); - resolve(null); - }, 1800); - - function onResponse(e) { - clearTimeout(timer); - window.removeEventListener('fg-response-media-' + reqId, onResponse); - resolve(e && e.detail ? e.detail : null); - } - - window.addEventListener('fg-response-media-' + reqId, onResponse); - window.dispatchEvent(new CustomEvent('fg-request-media', { - detail: { code: code, reqId: reqId } - })); - }); + function getCookie(name) { + try { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + } catch (_) {} + return ''; } - function extractVideoUrlFromFiber(rawNode) { + function getFbDtsg() { + try { + for (const script of document.scripts) { + const text = script.textContent || ''; + const match = text.match(/"DTSGInitialData",\[\],\{"token":"([^"]+)"/) || text.match(/"dtsg":\s*\{\s*"token":"([^"]+)"/); + if (match) return match[1]; + } + } catch (_) {} + return ''; + } + + function parseMediaItem(item, slideIndex = 0) { + if (!item) return null; + + // 1. Carousel check: carousel_media or edge_sidecar_to_children + if (item.carousel_media && Array.isArray(item.carousel_media) && item.carousel_media.length > 0) { + const target = item.carousel_media[slideIndex] || item.carousel_media[0]; + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.pk || item.id || parsed.id; + parsed.code = item.code || parsed.code; + parsed.author = item.user?.username || parsed.author; + return parsed; + } + } + if (item.edge_sidecar_to_children?.edges && Array.isArray(item.edge_sidecar_to_children.edges) && item.edge_sidecar_to_children.edges.length > 0) { + const edge = item.edge_sidecar_to_children.edges[slideIndex] || item.edge_sidecar_to_children.edges[0]; + const target = edge?.node; + if (target) { + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.id || parsed.id; + parsed.code = item.shortcode || parsed.code; + parsed.author = item.owner?.username || parsed.author; + return parsed; + } + } + } + + const isVideo = !!(item.is_video || item.media_type === 2 || (item.video_versions && item.video_versions.length > 0) || item.video_url); + let videoUrl = item.video_url || ''; + let imageUrl = item.display_url || ''; + + if (item.video_versions && Array.isArray(item.video_versions) && item.video_versions.length > 0) { + videoUrl = item.video_versions[0]?.url || videoUrl; + } + if (item.image_versions2?.candidates && Array.isArray(item.image_versions2.candidates) && item.image_versions2.candidates.length > 0) { + imageUrl = item.image_versions2.candidates[0]?.url || imageUrl; + } else if (item.display_resources && Array.isArray(item.display_resources) && item.display_resources.length > 0) { + imageUrl = item.display_resources[item.display_resources.length - 1]?.src || imageUrl; + } + + return { + isVideo: isVideo && !!videoUrl, + url: (isVideo && videoUrl) ? videoUrl : (imageUrl || videoUrl), + videoUrl: videoUrl, + imageUrl: imageUrl, + code: item.code || item.shortcode || '', + id: item.pk || item.id || '', + author: item.user?.username || item.owner?.username || '', + caption: item.caption?.text || item.edge_media_to_caption?.edges?.[0]?.node?.text || '' + }; + } + + async function fetchMediaByShortcode(shortcode, slideIndex = 0) { + if (!shortcode) return null; + + // 1. Try GraphQL PolarisPostRootQuery (Official Instagram-Downloader technique) + try { + const apiURL = new URL('/graphql/query', window.location.origin); + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'x-fb-friendly-name': 'PolarisPostRootQuery', + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest', + }; + const csrfToken = getCookie('csrftoken'); + if (csrfToken) headers['x-csrftoken'] = csrfToken; + + const fbDtsg = getFbDtsg(); + const bodyParams = { + fb_api_caller_class: 'RelayModern', + fb_api_req_friendly_name: 'PolarisPostRootQuery', + doc_id: IG_POST_ROOT_QUERY_DOC_ID, + variables: JSON.stringify({ + shortcode: shortcode, + __relay_internal__pv__PolarisShortDramaEnabledrelayprovider: false, + __relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: false, + }), + server_timestamps: 'true', + }; + if (fbDtsg) bodyParams.fb_dtsg = fbDtsg; + + const res = await fetch(apiURL.href, { + method: 'POST', + headers: headers, + body: new URLSearchParams(bodyParams).toString(), + credentials: 'include' + }); + + if (res.ok) { + const json = await res.json(); + const item = json?.data?.['xdt_api__v1__media__shortcode__web_info']?.items?.[0]; + if (item) { + return parseMediaItem(item, slideIndex); + } + } + } catch (e) { + console.warn('[FocusGram] GraphQL shortcode query error:', e); + } + + // 2. Fallback to /p/{shortcode}/?__a=1&__d=dis + try { + const res = await fetch(`/p/${shortcode}/?__a=1&__d=dis`, { + headers: { + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest' + }, + credentials: 'include' + }); + if (res.ok) { + const json = await res.json(); + const item = json?.items?.[0] || json?.graphql?.shortcode_media; + if (item) return parseMediaItem(item, slideIndex); + } + } catch (_) {} + + return null; + } + + function getActiveStreamFromPerformance() { + try { + const perf = (window.wrappedJSObject && window.wrappedJSObject.performance) || window.performance; + if (!perf || typeof perf.getEntriesByType !== 'function') return null; + const entries = perf.getEntriesByType('resource'); + for (let i = entries.length - 1; i >= 0 && i >= entries.length - 100; i--) { + const entry = entries[i]; + const name = entry.name || ''; + if ((name.includes('cdninstagram.com') || name.includes('fbcdn.net')) && + (name.includes('.mp4') || name.includes('/v/t50') || name.includes('/v/t') || name.includes('bytestart'))) { + if (name.startsWith('http://') || name.startsWith('https://')) { + try { + const u = new URL(name); + u.searchParams.delete('bytestart'); + u.searchParams.delete('byteend'); + return { url: u.toString(), isVideo: true }; + } catch (_) { + const cleanUrl = name.replace(/[?&]bytestart=\d+/, '').replace(/[?&]byteend=\d+/, ''); + return { url: cleanUrl, isVideo: true }; + } + } + } + } + } catch (_) {} + return null; + } + + function extractFromFiber(rawNode) { if (!rawNode) return null; try { const node = rawNode.wrappedJSObject || rawNode; @@ -2363,26 +2579,29 @@ for (let i = 0; i < 40 && fiber; i++) { const props = fiber.memoizedProps || fiber.pendingProps; if (props) { - if (props.videoUrl && typeof props.videoUrl === 'string' && props.videoUrl.startsWith('http')) { - return props.videoUrl; - } - if (props.src && typeof props.src === 'string' && props.src.startsWith('http') && !props.src.startsWith('blob:')) { - return props.src; - } if (props.video_versions && Array.isArray(props.video_versions) && props.video_versions[0]?.url) { - return props.video_versions[0].url; + return { url: props.video_versions[0].url, isVideo: true, code: props.code || props.shortcode || '' }; } - if (props.media && props.media.video_versions && props.media.video_versions[0]?.url) { - return props.media.video_versions[0].url; + if (props.media?.video_versions?.[0]?.url) { + return { url: props.media.video_versions[0].url, isVideo: true, code: props.media.code || '' }; } - if (props.item && props.item.video_versions && props.item.video_versions[0]?.url) { - return props.item.video_versions[0].url; + if (props.item?.video_versions?.[0]?.url) { + return { url: props.item.video_versions[0].url, isVideo: true, code: props.item.code || '' }; } - if (props.clip && props.clip.video_versions && props.clip.video_versions[0]?.url) { - return props.clip.video_versions[0].url; + if (props.clip?.video_versions?.[0]?.url) { + return { url: props.clip.video_versions[0].url, isVideo: true, code: props.clip.code || '' }; } - if (props.post && props.post.video_versions && props.post.video_versions[0]?.url) { - return props.post.video_versions[0].url; + if (props.post?.video_versions?.[0]?.url) { + return { url: props.post.video_versions[0].url, isVideo: true, code: props.post.code || '' }; + } + if (props.videoUrl && typeof props.videoUrl === 'string' && props.videoUrl.startsWith('http')) { + return { url: props.videoUrl, isVideo: true, code: props.code || '' }; + } + if (props.code && typeof props.code === 'string') { + return { code: props.code }; + } + if (props.shortcode && typeof props.shortcode === 'string') { + return { code: props.shortcode }; } } fiber = fiber.return; @@ -2393,14 +2612,13 @@ function extractBestDomUrl(container) { if (!container) return null; + // 1. Video check const video = container.querySelector('video') || (container.tagName === 'VIDEO' ? container : null); if (video) { - const fiberVideoUrl = extractVideoUrlFromFiber(video) || - extractVideoUrlFromFiber(video.parentElement) || - extractVideoUrlFromFiber(video.parentElement?.parentElement); - if (fiberVideoUrl) { - return { url: fiberVideoUrl, isVideo: true }; + const fiberData = extractFromFiber(video) || extractFromFiber(video.parentElement) || extractFromFiber(container); + if (fiberData && fiberData.url) { + return fiberData; } const vSrc = video.src || video.currentSrc || ''; @@ -2411,20 +2629,20 @@ if (source && source.src && (source.src.startsWith('http://') || source.src.startsWith('https://')) && !source.src.startsWith('blob:')) { return { url: source.src, isVideo: true }; } - if (video.poster && (video.poster.startsWith('http://') || video.poster.startsWith('https://'))) { - return { url: video.poster, isVideo: false }; - } } - const fiberContainerUrl = extractVideoUrlFromFiber(container); - if (fiberContainerUrl) { - return { url: fiberContainerUrl, isVideo: true }; + const fiberContainer = extractFromFiber(container); + if (fiberContainer && fiberContainer.url) { + return fiberContainer; } // 2. Image check (best candidate from srcset or src) const imgs = Array.from(container.querySelectorAll('img')).filter(img => { + const alt = (img.alt || '').toLowerCase(); + if (alt.includes('profile picture') || alt.includes("'s profile")) return false; const r = img.getBoundingClientRect(); - return r.width > 120 && r.height > 120; + if (r.width > 0 && r.width < 80) return false; + return true; }); const mainImg = imgs[0]; if (mainImg) { @@ -2432,12 +2650,12 @@ const parts = mainImg.srcset.split(',').map(s => s.trim().split(/\s+/)); const best = parts[parts.length - 1]?.[0]; if (best && (best.startsWith('http://') || best.startsWith('https://'))) { - return { url: best, isVideo: false }; + return { url: best.replace(/&/g, '&'), isVideo: false }; } } const iSrc = mainImg.currentSrc || mainImg.src; if (iSrc && (iSrc.startsWith('http://') || iSrc.startsWith('https://'))) { - return { url: iSrc, isVideo: false }; + return { url: iSrc.replace(/&/g, '&'), isVideo: false }; } } @@ -2450,224 +2668,11 @@ if (ogImage && ogImage.content && (ogImage.content.startsWith('http://') || ogImage.content.startsWith('https://'))) { return { url: ogImage.content, isVideo: false }; } - // 4. Any image inside container - const anyImg = container.querySelector('img[src*="cdninstagram"], img[src*="fbcdn"], img'); - if (anyImg) { - const src = anyImg.currentSrc || anyImg.src; - if (src && (src.startsWith('http://') || src.startsWith('https://'))) { - return { url: src, isVideo: false }; - } - } return null; } - async function handleMediaDownload(postContainer, shortcode, activeVideo) { - try { - // First attempt: fetch from in-page API harvest cache or GraphQL - let media = null; - if (shortcode) { - media = await requestMediaFromPage(shortcode); - } - - if (media && media.videoUrl && (media.videoUrl.startsWith('http://') || media.videoUrl.startsWith('https://'))) { - postToNative({ - action: 'downloadMedia', - url: media.videoUrl, - filename: 'instagram_' + (shortcode || Date.now()) + '.mp4' - }); - return; - } - - if (media && media.imageUrl && (media.imageUrl.startsWith('http://') || media.imageUrl.startsWith('https://'))) { - postToNative({ - action: 'downloadMedia', - url: media.imageUrl, - filename: 'instagram_' + (shortcode || Date.now()) + '.jpg' - }); - return; - } - - // Second attempt: extract from DOM - const domMedia = extractBestDomUrl(activeVideo || postContainer); - if (domMedia && domMedia.url) { - const ext = domMedia.isVideo ? '.mp4' : '.jpg'; - postToNative({ - action: 'downloadMedia', - url: domMedia.url, - filename: 'instagram_' + (shortcode || Date.now()) + ext - }); - return; - } - - // Third attempt: Resource Performance Timings for active streaming video - try { - const perfEntries = performance.getEntriesByType('resource'); - for (let i = perfEntries.length - 1; i >= 0 && i >= perfEntries.length - 40; i--) { - const entry = perfEntries[i]; - const name = entry.name || ''; - if ((name.includes('cdninstagram.com') || name.includes('fbcdn.net')) && - (name.includes('.mp4') || name.includes('bytestart') || name.includes('/v/t') || entry.initiatorType === 'video' || entry.initiatorType === 'fetch')) { - if (name.startsWith('http://') || name.startsWith('https://')) { - const cleanUrl = name.replace(/[?&]bytestart=\d+/, '').replace(/[?&]byteend=\d+/, ''); - postToNative({ - action: 'downloadMedia', - url: cleanUrl, - filename: 'instagram_' + (shortcode || Date.now()) + '.mp4' - }); - return; - } - } - } - } catch (_) {} - - // Failed to resolve - postToNative({ - action: 'downloadMedia', - url: '', - error: 'Could not extract video stream URL for this Reel' - }); - } catch (e) { - postToNative({ - action: 'downloadMedia', - url: '', - error: 'Download error: ' + (e.message || e) - }); - } - } - - function syncDownloadButtons() { - if (!currentSettings.videoDownloadEnabled) { - document.querySelectorAll('.' + FG_ACT_CLASS + ', .fg-feed-download-btn').forEach(btn => btn.remove()); - return; - } - - const candidates = new Set(); - - // 1. Polygon bookmark ribbon shape in action bar (points contain 20 21 or 21) - document.querySelectorAll('polygon').forEach(poly => { - const pts = poly.getAttribute('points') || ''; - if (pts.includes('21') || pts.includes('13.44') || pts.includes('13.4') || pts.includes('20 21')) { - const btn = poly.closest('div[role="button"], button') || poly.closest('svg')?.parentElement; - if (btn) candidates.add(btn); - } - }); - - // 2. Multilingual save/bookmark aria-labels - document.querySelectorAll('svg, [role="button"], button').forEach(el => { - const label = (el.getAttribute('aria-label') || '').toLowerCase(); - if (label.includes('save') || label.includes('guardar') || label.includes('salvar') || - label.includes('speichern') || label.includes('enregistrer') || label.includes('bookmark') || - label.includes('sauvegarder') || label.includes('remove') || label.includes('eliminar')) { - const btn = el.closest('div[role="button"], button') || (el.getAttribute('role') === 'button' ? el : el.parentElement); - if (btn) candidates.add(btn); - } - }); - - // 3. Structural action bar fallback (bookmark button is right-aligned in post action row) - document.querySelectorAll('article, div[role="presentation"]').forEach(post => { - const svgs = Array.from(post.querySelectorAll('svg')); - for (const svg of svgs) { - const btn = svg.closest('div[role="button"], button'); - if (!btn) continue; - const row = btn.closest('section') || btn.closest('div[style*="flex-direction: row"]') || btn.parentElement?.parentElement; - if (row && row.children.length >= 2 && !row.querySelector('[' + FG_ACT_ATTR + '="1"]')) { - const rightCol = row.children[row.children.length - 1]; - const saveBtn = rightCol.querySelector('div[role="button"], button') || rightCol; - if (saveBtn) { - candidates.add(saveBtn); - break; - } - } - } - }); - - candidates.forEach(btnContainer => { - if (!btnContainer || !btnContainer.parentElement) return; - const parent = btnContainer.parentElement; - - if (parent.querySelector('[' + FG_ACT_ATTR + '="1"]')) return; - - let postContainer = btnContainer.closest('article') || btnContainer.closest('section'); - if (!postContainer) { - let curr = btnContainer; - for (let i = 0; i < 8 && curr && curr !== document.body && curr.tagName !== 'MAIN'; i++) { - if (curr.querySelector && (curr.querySelector('video') || curr.querySelector('img[src*="cdninstagram"], img[src*="fbcdn"]'))) { - postContainer = curr; - break; - } - curr = curr.parentElement; - } - } - if (!postContainer) postContainer = parent.parentElement?.parentElement; - if (!postContainer) return; - - // Extract post shortcode if available - const link = postContainer.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); - const href = link ? (link.getAttribute('href') || '') : window.location.pathname; - const codeMatch = href.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); - const shortcode = codeMatch ? codeMatch[1] : ''; - - const dlBtn = document.createElement('div'); - dlBtn.className = FG_ACT_CLASS; - dlBtn.setAttribute(FG_ACT_ATTR, '1'); - dlBtn.setAttribute('data-fg-download-btn', '1'); - dlBtn.setAttribute('tabindex', '0'); - dlBtn.setAttribute('aria-label', 'Save Media'); - dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:6px;margin-right:6px;color:currentColor;opacity:0.9;vertical-align:middle;'; - dlBtn.innerHTML = ``; - - dlBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - handleMediaDownload(postContainer, shortcode); - }, true); - - parent.insertBefore(dlBtn, btnContainer); - }); - - // 4. Ensure every article in feed has a download button injected in its action bar - document.querySelectorAll('article').forEach(article => { - if (article.querySelector('[' + FG_ACT_ATTR + '="1"]')) return; - const likeBtn = article.querySelector('svg[aria-label*="Like" i], svg[aria-label*="Unlike" i], svg[aria-label*="Gefällt" i], svg[aria-label*="J’aime" i], svg[aria-label*="Curtir" i]')?.closest('div[role="button"], button'); - if (!likeBtn) return; - const actionRow = likeBtn.closest('section') || likeBtn.parentElement?.parentElement; - if (!actionRow) return; - - let saveBtn = actionRow.querySelector('svg[aria-label*="Save" i], svg[aria-label*="Bookmark" i], svg[aria-label*="Guardar" i]')?.closest('div[role="button"], button'); - let target = saveBtn; - let container = saveBtn ? saveBtn.parentElement : (actionRow.children.length > 1 ? actionRow.children[actionRow.children.length - 1] : actionRow); - - if (!container || container.querySelector('[' + FG_ACT_ATTR + '="1"]')) return; - - const link = article.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); - const href = link ? (link.getAttribute('href') || '') : ''; - const codeMatch = href.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); - const shortcode = codeMatch ? codeMatch[1] : ''; - - const dlBtn = document.createElement('div'); - dlBtn.className = FG_ACT_CLASS; - dlBtn.setAttribute(FG_ACT_ATTR, '1'); - dlBtn.setAttribute('data-fg-download-btn', '1'); - dlBtn.setAttribute('tabindex', '0'); - dlBtn.setAttribute('role', 'button'); - dlBtn.setAttribute('aria-label', 'Save Media'); - dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:6px;margin-right:6px;color:currentColor;opacity:0.9;vertical-align:middle;'; - dlBtn.innerHTML = ``; - - dlBtn.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - handleMediaDownload(article, shortcode); - }, true); - - if (target && target.parentElement === container) { - container.insertBefore(dlBtn, target); - } else { - container.appendChild(dlBtn); - } - }); - } + // Note: Media downloading (Feed, Reels, Profile Avatar) is handled exclusively by media_downloader.js function filterFeedAds() { if (currentSettings.noAds === false) return; @@ -2676,7 +2681,7 @@ 'patrocinado', 'sponsorizzato', 'paid partnership', 'werbung', 'anuncio', 'werbeanzeige' ]; const STRICT_AD_CTAS = [ - 'shop now', 'install now', 'sign up', 'apply now', 'book now', 'order now', 'get offer' + 'shop now', 'install now', 'apply now', 'book now', 'order now', 'get offer' ]; document.querySelectorAll('article').forEach(article => { @@ -2711,59 +2716,23 @@ if (isAd) { article.dataset.fgAdFiltered = 'true'; - article.style.setProperty('display', 'none', 'important'); - article.style.setProperty('height', '0px', 'important'); - article.style.setProperty('min-height', '0px', 'important'); - article.style.setProperty('max-height', '0px', 'important'); + article.style.setProperty('height', '1px', 'important'); + article.style.setProperty('min-height', '1px', 'important'); + article.style.setProperty('max-height', '1px', 'important'); article.style.setProperty('overflow', 'hidden', 'important'); article.style.setProperty('opacity', '0', 'important'); article.style.setProperty('pointer-events', 'none', 'important'); + article.style.setProperty('margin', '0', 'important'); + article.style.setProperty('padding', '0', 'important'); } }); } async function downloadActiveReel() { - try { - const vh = window.innerHeight; - const videos = Array.from(document.querySelectorAll('video')); - // Active video is the one currently visible and playing, or closest to center - let activeVideo = null; - let minCenterDist = Infinity; - for (const v of videos) { - const r = v.getBoundingClientRect(); - if (r.height > 100 && r.bottom > 0 && r.top < vh) { - const dist = Math.abs((r.top + r.height / 2) - vh / 2); - if (!v.paused) { - activeVideo = v; - break; - } - if (dist < minCenterDist) { - minCenterDist = dist; - activeVideo = v; - } - } - } - if (!activeVideo && videos.length > 0) activeVideo = videos[0]; - - const card = activeVideo ? (activeVideo.closest('[data-fg-reel-card]') || activeVideo.closest('article') || activeVideo.parentElement) : document; - - let code = null; - if (card) { - const reelLink = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); - if (reelLink && reelLink.href) { - const m = reelLink.href.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); - if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; - } - } - if (!code) { - const m = window.location.pathname.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); - if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; - } - - await handleMediaDownload(card, code, activeVideo); - } catch (e) { - console.error('[FocusGram] downloadActiveReel error:', e); - postToNative({ action: 'downloadMedia', url: '', error: 'Download error: ' + (e.message || e) }); + if (typeof window.__fgDownloadActiveReel === 'function' && window.__fgDownloadActiveReel !== downloadActiveReel) { + window.__fgDownloadActiveReel(); + } else { + window.dispatchEvent(new CustomEvent('focusgram:download-reel', { bubbles: true })); } } @@ -2775,7 +2744,6 @@ fixReels(); filterFeedAds(); cleanUpDynamicContent(); - syncDownloadButtons(); }, 250); // --- 4. INPUT FOCUS / TYPING BRIDGE --- @@ -2949,15 +2917,42 @@ // Senior Blueprint: Direct content script communication with WebExtension background script function postToNative(message) { - if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { - try { - browser.runtime.sendMessage(message); - } catch (e) { - console.warn('[FocusGram] sendMessage error:', e); + if (!message) return; + try { + if (typeof browser !== 'undefined' && browser.runtime) { + if (browser.runtime.sendNativeMessage) { + browser.runtime.sendNativeMessage("focusgram", message).catch(() => {}); + } + if (browser.runtime.sendMessage) { + browser.runtime.sendMessage(message).catch(() => {}); + } } + } catch (e) { + console.warn('[FocusGram] postToNative error:', e); } } + // Bridge window events from external/page scripts to WebExtension native IPC + window.addEventListener('focusgram:download-request', (e) => { + if (!e || !e.detail) return; + postToNative({ + action: 'downloadMedia', + url: e.detail.url, + filename: e.detail.filename, + error: e.detail.error + }); + }); + window.addEventListener('message', (e) => { + if (e && e.data && e.data.type === 'FOCUSGRAM_DOWNLOAD_REQUEST') { + postToNative({ + action: 'downloadMedia', + url: e.data.url, + filename: e.data.filename, + error: e.data.error + }); + } + }); + // --- INSTAGRAM THEME DETECTION --- // Detect if Instagram is in light or dark mode and notify native let lastSentTheme = null; @@ -3137,6 +3132,12 @@ }, { passive: true }); window.addEventListener('scrollend', checkAndTriggerInfiniteScroll, { passive: true }); window.addEventListener('focusgram:download-reel', downloadActiveReel); + try { + window.__fgDownloadActiveReel = downloadActiveReel; + if (typeof window.wrappedJSObject !== 'undefined') { + window.wrappedJSObject.__fgDownloadActiveReel = downloadActiveReel; + } + } catch (_) {} })(); diff --git a/Ported/app/src/main/assets/extensions/ublock/manifest.json b/Ported/app/src/main/assets/extensions/ublock/manifest.json index 7fc2370..1324918 100644 --- a/Ported/app/src/main/assets/extensions/ublock/manifest.json +++ b/Ported/app/src/main/assets/extensions/ublock/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "FocusGram Content Engine", - "version": "1.61.0", + "version": "2.1.0", "description": "Privacy-preserving network-level content blocking, stealth protection, and cosmetic filtering for FocusGram.", "author": "FocusGram Project", "homepage_url": "https://github.com/Ujwal223/FocusGram", diff --git a/Ported/app/src/main/assets/scripts/autoplay_blocker.js b/Ported/app/src/main/assets/scripts/autoplay_blocker.js index a1c2bb7..ae475ca 100644 --- a/Ported/app/src/main/assets/scripts/autoplay_blocker.js +++ b/Ported/app/src/main/assets/scripts/autoplay_blocker.js @@ -8,6 +8,19 @@ 'use strict'; (function () { + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; + var c = document.cookie || ''; + if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) return; // Leave login/signup completely untouched + if (window.__fgAutoplayPatched) return; window.__fgAutoplayPatched = true; diff --git a/Ported/app/src/main/assets/scripts/content_hider.js b/Ported/app/src/main/assets/scripts/content_hider.js index 2a69fb1..b44fe7c 100644 --- a/Ported/app/src/main/assets/scripts/content_hider.js +++ b/Ported/app/src/main/assets/scripts/content_hider.js @@ -13,6 +13,23 @@ (function () { const STYLE_ID = 'focusgram-dynamic-guardrails-styles'; + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; + var c = document.cookie || ''; + if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) { + var existing = document.getElementById(STYLE_ID); + if (existing) existing.remove(); + return; // Leave login/signup completely untouched + } + // 1. Client-Side SPA Navigation Helper window.__focusgramNavigate = function (path) { if (!path) return; @@ -286,16 +303,13 @@ if (config.noAds !== false) { css += ` - article:has(a[href*="/ad_"]), article:has(a[href*="/ads/"]), article:has(a[href*="ads.instagram.com"]), - article:has([aria-label="Ad" i]), article:has([aria-label*="Sponsored" i]), article[data-fg-ad-filtered="true"] { - display: none !important; - height: 0px !important; - min-height: 0px !important; - max-height: 0px !important; + height: 1px !important; + min-height: 1px !important; + max-height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; @@ -309,8 +323,7 @@ } div[data-ad-preview], div[data-testid="post_ad"], - div[data-testid="suggested-users"], - div[aria-label="Ad" i]:not(main):not([role="feed"]) { + div[data-testid="suggested-users"] { display: none !important; } `; @@ -373,7 +386,6 @@ article.style.setProperty('margin', '0', 'important'); article.style.setProperty('padding', '0', 'important'); article.dataset.fgAdFiltered = 'true'; - window.dispatchEvent(new Event('scroll')); } }); diff --git a/Ported/app/src/main/assets/scripts/fetch_interceptor.js b/Ported/app/src/main/assets/scripts/fetch_interceptor.js index b39db48..18607fc 100644 --- a/Ported/app/src/main/assets/scripts/fetch_interceptor.js +++ b/Ported/app/src/main/assets/scripts/fetch_interceptor.js @@ -12,6 +12,19 @@ 'use strict'; (function () { + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; + var c = document.cookie || ''; + if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) return; // Leave login/signup completely untouched + if (window.__fgFetchInterceptorInstalled) return; window.__fgFetchInterceptorInstalled = true; @@ -27,7 +40,7 @@ const AD_KEYWORDS = [ 'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung', - 'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags' + 'anuncio', 'werbeanzeige', 'publicité' ]; function textHasAdSignal(value) { @@ -39,69 +52,55 @@ function isAdNode(node) { if (!node || typeof node !== 'object') return false; const typename = String(node.__typename || ''); - const adText = JSON.stringify({ - organic_tracking_token: node.organic_tracking_token, - sponsor_tags: node.sponsor_tags, - social_context: node.social_context, - title: node.title, - header: node.header, - label: node.label, - overlay_text: node.overlay_text - }); + if (node.is_ad || node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) { + return true; + } + if (node.ad_id || node.ad_action_link || node.ad_impression_token || node.ad_metadata) { + return true; + } + if (node.commerciality_status === 'commercial' || (node.product_type && node.product_type === 'ad')) { + return true; + } + if (node.ad_header_style && node.ad_header_style !== 'none') { + return true; + } + if (typename === 'GraphAdStory' || typename.includes('Ad')) { + return true; + } - return !!( - node.is_ad || - node.is_paid_partnership || - (node.sponsor_tags && node.sponsor_tags.length > 0) || - node.ad_tracking_token || - node.ad_action_link || - node.ad_id || - node.ad_impression_token || - node.ad_metadata || - node.commerciality_status === 'commercial' || - (node.product_type && node.product_type === 'ad') || - (node.ad_header_style && node.ad_header_style !== 'none') || - typename === 'GraphAdStory' || - typename.includes('Ad') || - textHasAdSignal(adText) - ); + const values = [node.social_context, node.title, node.header, node.label, node.overlay_text] + .filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' '); + + return textHasAdSignal(values); } function isSponsoredNode(node) { if (!node || typeof node !== 'object') return false; - return !!( - node.is_paid_partnership || - (node.sponsor_tags && node.sponsor_tags.length > 0) || - (node.ad_action_link && node.ad_action_link.href) || - (node.ad_header_style && node.ad_header_style !== 'none') || - textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || '')) - ); + if (node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) { + return true; + } + if (node.ad_action_link && node.ad_action_link.href) { + return true; + } + if (node.ad_header_style && node.ad_header_style !== 'none') { + return true; + } + const text = [node.social_context, node.header, node.label].filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' '); + return textHasAdSignal(text); } function isSuggestedNode(node) { if (!node || typeof node !== 'object') return false; const typename = String(node.__typename || ''); - const reason = JSON.stringify({ - reason: node.suggested_reason, - social_context: node.social_context, - title: node.title, - header: node.header, - label: node.label - }).toLowerCase(); - - return !!( - node.is_suggested || - node.is_suggested_for_you || - node.is_recommendation || - node.suggested_users || - node.suggested_media || - node.suggested_content || - node.recommendation_source || - typename.includes('Suggested') || - typename.includes('Recommendation') || - reason.includes('suggested') || - reason.includes('recommend') - ); + if (node.is_suggested || node.is_suggested_for_you || node.is_recommendation || + node.suggested_users || node.suggested_media || node.suggested_content || + node.recommendation_source || typename.includes('Suggested') || typename.includes('Recommendation') + ) { + return true; + } + const text = [node.suggested_reason, node.social_context, node.title, node.header, node.label] + .filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ').toLowerCase(); + return text.includes('suggested') || text.includes('recommend'); } function shouldFilterNode(node) { @@ -128,7 +127,7 @@ function unwrapNode(item) { if (!item || typeof item !== 'object') return item; - return item.media || item.node || item.post || item; + return item.media_or_ad || item.media || item.node || item.post || item; } function filterList(list) { @@ -165,6 +164,8 @@ } for (const key in obj) { + // Never filter pagination or connection metadata + if (key === 'page_info' || key === 'pagination_info' || key === 'paging_info') continue; if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') { const val = obj[key]; if (val && typeof val === 'object') { @@ -174,6 +175,62 @@ } } + window.__fg_media_harvest = window.__fg_media_harvest || {}; + window.__fg_recent_media = window.__fg_recent_media || []; + + function harvestMediaFromData(obj, depth = 0) { + if (!obj || typeof obj !== 'object' || depth > 12) return; + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) harvestMediaFromData(obj[i], depth + 1); + return; + } + + const code = obj.code || (obj.shortcode ? obj.shortcode : null); + if (code && (obj.video_versions || obj.image_versions2 || obj.carousel_media)) { + const isVideo = !!(obj.is_video || (obj.video_versions && obj.video_versions.length > 0)); + const videoUrl = (obj.video_versions && obj.video_versions[0]?.url) || ''; + const imageUrl = (obj.image_versions2?.candidates && obj.image_versions2.candidates[0]?.url) || ''; + + const carousel = []; + if (Array.isArray(obj.carousel_media)) { + for (let c = 0; c < obj.carousel_media.length; c++) { + const cItem = obj.carousel_media[c]; + const cIsVideo = !!(cItem.is_video || (cItem.video_versions && cItem.video_versions.length > 0)); + const cVideoUrl = (cItem.video_versions && cItem.video_versions[0]?.url) || ''; + const cImageUrl = (cItem.image_versions2?.candidates && cItem.image_versions2.candidates[0]?.url) || ''; + carousel.push({ + url: cIsVideo && cVideoUrl ? cVideoUrl : cImageUrl, + videoUrl: cVideoUrl, + imageUrl: cImageUrl, + isVideo: cIsVideo && !!cVideoUrl + }); + } + } + + const entry = { + code: code, + videoUrl: videoUrl, + imageUrl: imageUrl, + url: isVideo && videoUrl ? videoUrl : (imageUrl || videoUrl), + isVideo: isVideo && !!videoUrl, + carousel: carousel, + author: obj.user?.username || obj.owner?.username || '' + }; + + window.__fg_media_harvest[code] = entry; + window.__fg_recent_media.unshift(entry); + if (window.__fg_recent_media.length > 100) { + window.__fg_recent_media.pop(); + } + } + + for (const k in obj) { + if (Object.prototype.hasOwnProperty.call(obj, k) && k !== '__typename' && typeof obj[k] === 'object') { + harvestMediaFromData(obj[k], depth + 1); + } + } + } + // Override fetch in the main world const _fetch = window.fetch.bind(window); @@ -204,12 +261,20 @@ } const data = await cloned.json(); + harvestMediaFromData(data); walkAndFilter(data); + const newHeaders = new Headers(response.headers); + // CRITICAL: Must delete content-length and content-encoding so the uncompressed + // JSON body is not truncated or corrupted by the browser HTTP layer + newHeaders.delete('content-length'); + newHeaders.delete('content-encoding'); + newHeaders.set('content-type', 'application/json; charset=utf-8'); + return new Response(JSON.stringify(data), { status: response.status, statusText: response.statusText, - headers: response.headers + headers: newHeaders }); } catch (_) { return response; diff --git a/Ported/app/src/main/assets/scripts/media_downloader.js b/Ported/app/src/main/assets/scripts/media_downloader.js new file mode 100644 index 0000000..a6f6942 --- /dev/null +++ b/Ported/app/src/main/assets/scripts/media_downloader.js @@ -0,0 +1,1008 @@ +/** + * FocusGram — Standalone In-Page Media Downloader + * Injected directly into the page context by UserScriptManager. + * + * Provides: + * 1. Home Feed download buttons (inline next to Save/Share, or overlay fallback). + * 2. Reels download buttons (in-page vertical action column + ReelsTopBar native trigger). + * 3. Profile avatar HD downloader badge. + * 4. Multi-tier resolution: GraphQL PolarisPostRootQuery -> React Fiber -> DOM -> Performance Resource Timings -> In-Memory Harvest Cache. + * 5. Direct communication with native Android host via 'focusgram-download://' URI interception. + */ + +'use strict'; + +(function () { + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; + var c = document.cookie || ''; + if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) return; // Leave login/signup completely untouched + + if (window.__fgMediaDownloaderInstalled) return; + window.__fgMediaDownloaderInstalled = true; + + const FG_DL_ATTR = 'data-fg-download-btn'; + const IG_POST_ROOT_QUERY_DOC_ID = '27852811784380813'; + + function isDownloadEnabled() { + if (window.__FOCUSGRAM_CONFIG__ && window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled !== undefined) { + return !!window.__FOCUSGRAM_CONFIG__.videoDownloadEnabled; + } + return false; + } + + let isGlobalScrolling = false; + let globalScrollTimeout = null; + let lastScrollTime = 0; + window.addEventListener('scroll', () => { + isGlobalScrolling = true; + lastScrollTime = Date.now(); + clearTimeout(globalScrollTimeout); + globalScrollTimeout = setTimeout(() => { + isGlobalScrolling = false; + }, 250); + }, { passive: true, capture: true }); + + function triggerNativeDownload(url, filename, error) { + if (url) { + try { + window.location.href = 'focusgram-download://download?url=' + encodeURIComponent(url) + '&filename=' + encodeURIComponent(filename || ''); + } catch (_) {} + } + + // 1. Try WebExtension native messaging bridge first + try { + if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) { + browser.runtime.sendMessage({ + action: 'downloadMedia', + url: url || '', + filename: filename || '', + error: error || '' + }); + return; + } + } catch (_) {} + + // 2. Dispatch event for content script bridge + try { + window.dispatchEvent(new CustomEvent('focusgram:download-request', { + detail: { url: url || '', filename: filename || '', error: error || '' } + })); + window.postMessage({ + type: 'FOCUSGRAM_DOWNLOAD_REQUEST', + url: url || '', + filename: filename || '', + error: error || '' + }, '*'); + } catch (e) { + console.error('[FocusGram Downloader] triggerNativeDownload error:', e); + } + } + + function getCookie(name) { + try { + const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()\[\]\\\/+^])/g, '\\$1') + '=([^;]*)')); + return match ? decodeURIComponent(match[1]) : ''; + } catch (_) {} + return ''; + } + + function getFbDtsg() { + try { + for (const script of document.scripts) { + const text = script.textContent || ''; + const match = text.match(/"DTSGInitialData",\[\],\{"token":"([^"]+)"/) || text.match(/"dtsg":\s*\{\s*"token":"([^"]+)"/); + if (match) return match[1]; + } + } catch (_) {} + return ''; + } + + function parseMediaItem(item, slideIndex = 0) { + if (!item) return null; + + // 1. Carousel check: carousel_media or edge_sidecar_to_children + if (item.carousel_media && Array.isArray(item.carousel_media) && item.carousel_media.length > 0) { + const target = item.carousel_media[slideIndex] || item.carousel_media[0]; + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.pk || item.id || parsed.id; + parsed.code = item.code || parsed.code; + parsed.author = item.user?.username || parsed.author; + return parsed; + } + } + if (item.edge_sidecar_to_children?.edges && Array.isArray(item.edge_sidecar_to_children.edges) && item.edge_sidecar_to_children.edges.length > 0) { + const edge = item.edge_sidecar_to_children.edges[slideIndex] || item.edge_sidecar_to_children.edges[0]; + const target = edge?.node; + if (target) { + const parsed = parseMediaItem(target, 0); + if (parsed) { + parsed.id = item.id || parsed.id; + parsed.code = item.shortcode || parsed.code; + parsed.author = item.owner?.username || parsed.author; + return parsed; + } + } + } + + const isVideo = !!(item.is_video || item.media_type === 2 || (item.video_versions && item.video_versions.length > 0) || item.video_url); + let videoUrl = item.video_url || ''; + let imageUrl = item.display_url || ''; + + if (item.video_versions && Array.isArray(item.video_versions) && item.video_versions.length > 0) { + videoUrl = item.video_versions[0]?.url || videoUrl; + } + if (item.image_versions2?.candidates && Array.isArray(item.image_versions2.candidates) && item.image_versions2.candidates.length > 0) { + imageUrl = item.image_versions2.candidates[0]?.url || imageUrl; + } else if (item.display_resources && Array.isArray(item.display_resources) && item.display_resources.length > 0) { + imageUrl = item.display_resources[item.display_resources.length - 1]?.src || imageUrl; + } + + return { + isVideo: isVideo && !!videoUrl, + url: (isVideo && videoUrl) ? videoUrl : (imageUrl || videoUrl), + videoUrl: videoUrl, + imageUrl: imageUrl, + code: item.code || item.shortcode || '', + id: item.pk || item.id || '', + author: item.user?.username || item.owner?.username || '', + caption: item.caption?.text || item.edge_media_to_caption?.edges?.[0]?.node?.text || '' + }; + } + + async function fetchMediaByShortcode(shortcode, slideIndex = 0) { + if (!shortcode) return null; + + // 1. Try GraphQL PolarisPostRootQuery + try { + const apiURL = new URL('/graphql/query', window.location.origin); + const headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'x-fb-friendly-name': 'PolarisPostRootQuery', + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest', + }; + const csrfToken = getCookie('csrftoken'); + if (csrfToken) headers['x-csrftoken'] = csrfToken; + + const fbDtsg = getFbDtsg(); + const bodyParams = { + fb_api_caller_class: 'RelayModern', + fb_api_req_friendly_name: 'PolarisPostRootQuery', + doc_id: IG_POST_ROOT_QUERY_DOC_ID, + variables: JSON.stringify({ + shortcode: shortcode, + __relay_internal__pv__PolarisShortDramaEnabledrelayprovider: false, + __relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: false, + }), + server_timestamps: 'true', + }; + if (fbDtsg) bodyParams.fb_dtsg = fbDtsg; + + const res = await fetch(apiURL.href, { + method: 'POST', + headers: headers, + body: new URLSearchParams(bodyParams).toString(), + credentials: 'include' + }); + + if (res.ok) { + const json = await res.json(); + const item = json?.data?.['xdt_api__v1__media__shortcode__web_info']?.items?.[0]; + if (item) { + return parseMediaItem(item, slideIndex); + } + } + } catch (e) { + console.warn('[FocusGram Downloader] GraphQL shortcode query error:', e); + } + + // 2. Fallback to /p/{shortcode}/?__a=1&__d=dis + try { + const res = await fetch(`/p/${shortcode}/?__a=1&__d=dis`, { + headers: { + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest' + }, + credentials: 'include' + }); + if (res.ok) { + const json = await res.json(); + const item = json?.items?.[0] || json?.graphql?.shortcode_media; + if (item) return parseMediaItem(item, slideIndex); + } + } catch (_) {} + + return null; + } + + function isLikelyImageUrl(url) { + if (!url || typeof url !== 'string') return false; + const lower = url.toLowerCase(); + return lower.includes('.jpg') || lower.includes('.jpeg') || lower.includes('.png') || + lower.includes('.webp') || lower.includes('dst-jpg') || lower.includes('dst-png') || + lower.includes('dst-webp') || lower.startsWith('data:image'); + } + + function isLikelyVideoUrl(url) { + if (!url || typeof url !== 'string') return false; + if (isLikelyImageUrl(url)) return false; + const lower = url.toLowerCase(); + return lower.includes('.mp4') || lower.includes('.webm') || lower.includes('/v/t50.') || + lower.includes('/v/t16.') || lower.includes('video_dashinit') || lower.startsWith('data:video'); + } + + function getActiveStreamFromPerformance() { + try { + const perf = (window.wrappedJSObject && window.wrappedJSObject.performance) || window.performance; + if (!perf || typeof perf.getEntriesByType !== 'function') return null; + const entries = perf.getEntriesByType('resource'); + for (let i = entries.length - 1; i >= 0 && i >= entries.length - 100; i--) { + const entry = entries[i]; + const name = entry.name || ''; + if (!name.startsWith('http://') && !name.startsWith('https://')) continue; + if (isLikelyImageUrl(name)) continue; // STRICTLY IGNORE ALL IMAGES + if (!name.includes('cdninstagram.com') && !name.includes('fbcdn.net')) continue; + + if (name.includes('.mp4') || name.includes('/v/t50.') || name.includes('/v/t16.') || + name.includes('video_dashinit') || (name.includes('bytestart') && !isLikelyImageUrl(name))) { + try { + const u = new URL(name); + u.searchParams.delete('bytestart'); + u.searchParams.delete('byteend'); + return { url: u.toString(), isVideo: true }; + } catch (_) { + const cleanUrl = name.replace(/[?&]bytestart=\d+/, '').replace(/[?&]byteend=\d+/, ''); + return { url: cleanUrl, isVideo: true }; + } + } + } + } catch (_) {} + return null; + } + + function extractFromFiber(node) { + if (!node) return null; + try { + const key = Object.keys(node).find(k => k.startsWith('__reactFiber') || k.startsWith('__reactProps')); + if (!key) return null; + let fiber = node[key]; + for (let i = 0; i < 40 && fiber; i++) { + const props = fiber.memoizedProps || fiber.pendingProps; + if (props) { + const author = props.user?.username || props.owner?.username || props.media?.user?.username || props.media?.owner?.username || props.item?.user?.username || props.clip?.user?.username || ''; + const code = props.code || props.shortcode || props.media?.code || props.media?.shortcode || props.item?.code || props.clip?.code || props.post?.code || ''; + + if (props.video_versions && Array.isArray(props.video_versions) && props.video_versions[0]?.url) { + return { url: props.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.media?.video_versions?.[0]?.url) { + return { url: props.media.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.item?.video_versions?.[0]?.url) { + return { url: props.item.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.clip?.video_versions?.[0]?.url) { + return { url: props.clip.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.post?.video_versions?.[0]?.url) { + return { url: props.post.video_versions[0].url, isVideo: true, code: code, author: author }; + } + if (props.videoUrl && typeof props.videoUrl === 'string' && props.videoUrl.startsWith('http') && isLikelyVideoUrl(props.videoUrl)) { + return { url: props.videoUrl, isVideo: true, code: code, author: author }; + } + if (props.display_url && typeof props.display_url === 'string' && props.display_url.startsWith('http')) { + return { url: props.display_url, isVideo: false, code: code, author: author }; + } + if (code || author) { + return { code: code, author: author }; + } + } + fiber = fiber.return; + } + } catch (_) {} + return null; + } + + function extractBestDomUrl(container, isVideoExpected = false) { + if (!container) return null; + + const video = container.querySelector('video') || (container.tagName === 'VIDEO' ? container : null); + const hasVideo = !!video || isVideoExpected; + + if (hasVideo) { + const fiberData = (video && (extractFromFiber(video) || extractFromFiber(video.parentElement))) || extractFromFiber(container); + if (fiberData && fiberData.url && isLikelyVideoUrl(fiberData.url)) { + return fiberData; + } + + if (video) { + const vSrc = video.src || video.currentSrc || ''; + if (vSrc && (vSrc.startsWith('http://') || vSrc.startsWith('https://')) && !vSrc.startsWith('blob:') && isLikelyVideoUrl(vSrc)) { + return { url: vSrc, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + const source = video.querySelector('source'); + if (source && source.src && (source.src.startsWith('http://') || source.src.startsWith('https://')) && !source.src.startsWith('blob:') && isLikelyVideoUrl(source.src)) { + return { url: source.src, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + } + + const ogVideo = document.querySelector('meta[property="og:video"], meta[property="og:video:secure_url"]'); + if (ogVideo && ogVideo.content && (ogVideo.content.startsWith('http://') || ogVideo.content.startsWith('https://')) && isLikelyVideoUrl(ogVideo.content)) { + return { url: ogVideo.content, isVideo: true, author: fiberData?.author, code: fiberData?.code }; + } + + // CRITICAL: When video is expected, NEVER return an image! + if (fiberData && (fiberData.code || fiberData.author)) { + return { url: '', isVideo: true, code: fiberData.code, author: fiberData.author }; + } + return null; + } + + // PHOTO ONLY + const fiberContainer = extractFromFiber(container); + if (fiberContainer && fiberContainer.url && !isLikelyVideoUrl(fiberContainer.url)) { + return fiberContainer; + } + + const imgs = Array.from(container.querySelectorAll('img')).filter(img => { + const r = img.getBoundingClientRect(); + return r.width > 120 && r.height > 120; + }); + const mainImg = imgs[0]; + if (mainImg) { + if (mainImg.srcset) { + const parts = mainImg.srcset.split(',').map(s => s.trim().split(/\s+/)); + const best = parts[parts.length - 1]?.[0]; + if (best && (best.startsWith('http://') || best.startsWith('https://'))) { + return { url: best.replace(/&/g, '&'), isVideo: false, code: fiberContainer?.code, author: fiberContainer?.author }; + } + } + const iSrc = mainImg.currentSrc || mainImg.src; + if (iSrc && (iSrc.startsWith('http://') || iSrc.startsWith('https://'))) { + return { url: iSrc.replace(/&/g, '&'), isVideo: false, code: fiberContainer?.code, author: fiberContainer?.author }; + } + } + + const ogImage = document.querySelector('meta[property="og:image"]'); + if (ogImage && ogImage.content && (ogImage.content.startsWith('http://') || ogImage.content.startsWith('https://'))) { + return { url: ogImage.content, isVideo: false }; + } + + return null; + } + + async function handleMediaDownload(postContainer, shortcode, activeVideo) { + try { + // Determine active slide index for carousels + let slideIndex = 0; + if (postContainer) { + const activeStep = postContainer.querySelector('button[aria-current="step"]'); + if (activeStep && activeStep.parentElement) { + const steps = Array.from(activeStep.parentElement.querySelectorAll('button')); + const idx = steps.indexOf(activeStep); + if (idx >= 0) slideIndex = idx; + } + } + + const hasVideoElement = !!(activeVideo || (postContainer && (postContainer.querySelector('video') || postContainer.tagName === 'VIDEO')) || window.location.pathname.includes('/reel')); + + let resolvedUrl = ''; + let isVideo = hasVideoElement; + let author = ''; + + // 1. Instant check in harvested in-memory media cache (GraphQL/Feed/Clips responses) + try { + const harvest = (window.wrappedJSObject && window.wrappedJSObject.__fg_media_harvest) || window.__fg_media_harvest; + if (harvest && shortcode && harvest[shortcode]) { + const cached = harvest[shortcode]; + if (cached.author) author = cached.author; + if (cached.carousel && cached.carousel.length > 0 && cached.carousel[slideIndex]) { + const slide = cached.carousel[slideIndex]; + if (hasVideoElement) { + if (slide.isVideo && slide.videoUrl && isLikelyVideoUrl(slide.videoUrl)) { + resolvedUrl = slide.videoUrl; + isVideo = true; + } + } else { + if (!slide.isVideo && slide.imageUrl && isLikelyImageUrl(slide.imageUrl)) { + resolvedUrl = slide.imageUrl; + isVideo = false; + } else if (slide.url && !isLikelyVideoUrl(slide.url)) { + resolvedUrl = slide.url; + isVideo = false; + } + } + } else { + if (hasVideoElement) { + if (cached.videoUrl && isLikelyVideoUrl(cached.videoUrl)) { + resolvedUrl = cached.videoUrl; + isVideo = true; + } + } else { + if (cached.imageUrl && isLikelyImageUrl(cached.imageUrl)) { + resolvedUrl = cached.imageUrl; + isVideo = false; + } else if (cached.url && !isLikelyVideoUrl(cached.url)) { + resolvedUrl = cached.url; + isVideo = false; + } + } + } + } + } catch (_) {} + + // 2. Active stream from performance timing entries — STRICTLY FOR VIDEOS ONLY! + if (!resolvedUrl && hasVideoElement) { + const streamMedia = getActiveStreamFromPerformance(); + if (streamMedia && streamMedia.url && isLikelyVideoUrl(streamMedia.url)) { + resolvedUrl = streamMedia.url; + isVideo = true; + } + } + + // 3. Check recent media harvest queue (especially for Reels viewer where URL lacks shortcode) + if (!resolvedUrl) { + try { + const recents = (window.wrappedJSObject && window.wrappedJSObject.__fg_recent_media) || window.__fg_recent_media; + if (recents && recents.length > 0) { + const authorEl = postContainer ? (postContainer.querySelector('header a, a[role="link"]:not([href*="audio"]):not([href*="explore"]):not([href*="/p/"]):not([href*="/reel/"])')) : null; + const cardAuthor = (authorEl?.textContent || '').trim().toLowerCase().replace('@', ''); + if (cardAuthor) { + const match = recents.find(r => (r.author || '').toLowerCase() === cardAuthor); + if (match) { + if (!author && match.author) author = match.author; + if (hasVideoElement && match.videoUrl && isLikelyVideoUrl(match.videoUrl)) { + resolvedUrl = match.videoUrl; + isVideo = true; + } else if (!hasVideoElement && (match.imageUrl || match.url)) { + const candidate = match.imageUrl || match.url; + if (!isLikelyVideoUrl(candidate)) { + resolvedUrl = candidate; + isVideo = false; + } + } + if (!shortcode && match.code) shortcode = match.code; + } + } + if (!resolvedUrl && (window.location.pathname.includes('/reels') || window.location.pathname.includes('/reel/'))) { + const top = recents[0]; + if (top) { + if (!author && top.author) author = top.author; + if (hasVideoElement && top.videoUrl && isLikelyVideoUrl(top.videoUrl)) { + resolvedUrl = top.videoUrl; + isVideo = true; + } else if (!hasVideoElement && (top.imageUrl || top.url)) { + const candidate = top.imageUrl || top.url; + if (!isLikelyVideoUrl(candidate)) { + resolvedUrl = candidate; + isVideo = false; + } + } + if (!shortcode && top.code) shortcode = top.code; + } + } + } + } catch (_) {} + } + + // 4. Check DOM & React Fiber on activeVideo or container + if (!resolvedUrl) { + const domMedia = extractBestDomUrl(activeVideo || postContainer, hasVideoElement); + if (domMedia && domMedia.url && domMedia.url.startsWith('http')) { + if (hasVideoElement && isLikelyVideoUrl(domMedia.url)) { + resolvedUrl = domMedia.url; + isVideo = true; + } else if (!hasVideoElement && !isLikelyVideoUrl(domMedia.url)) { + resolvedUrl = domMedia.url; + isVideo = false; + } + } + if (!shortcode && domMedia?.code) shortcode = domMedia.code; + if (!author && domMedia?.author) author = domMedia.author; + } + + // 5. Query Instagram API via shortcode + if ((!resolvedUrl || (hasVideoElement && isLikelyImageUrl(resolvedUrl))) && shortcode) { + const apiMedia = await fetchMediaByShortcode(shortcode, slideIndex); + if (apiMedia && apiMedia.url && apiMedia.url.startsWith('http')) { + if (hasVideoElement) { + const targetVid = (apiMedia.videoUrl && isLikelyVideoUrl(apiMedia.videoUrl)) ? apiMedia.videoUrl : (isLikelyVideoUrl(apiMedia.url) ? apiMedia.url : ''); + if (targetVid) { + resolvedUrl = targetVid; + isVideo = true; + } + } else { + const targetImg = (apiMedia.imageUrl && isLikelyImageUrl(apiMedia.imageUrl)) ? apiMedia.imageUrl : (!isLikelyVideoUrl(apiMedia.url) ? apiMedia.url : ''); + if (targetImg) { + resolvedUrl = targetImg; + isVideo = false; + } + } + if (!author && apiMedia.author) author = apiMedia.author; + } + } + + // 6. Last-resort fallback for video streams if still empty + if (hasVideoElement && (!resolvedUrl || isLikelyImageUrl(resolvedUrl))) { + const streamFallback = getActiveStreamFromPerformance(); + if (streamFallback && streamFallback.url && isLikelyVideoUrl(streamFallback.url)) { + resolvedUrl = streamFallback.url; + isVideo = true; + } + } + + // Extract author from DOM if still missing + if (!author && postContainer) { + const authorEl = postContainer.querySelector('header a, a[role="link"]:not([href*="audio"]):not([href*="explore"]):not([href*="/p/"]):not([href*="/reel/"])'); + if (authorEl && authorEl.textContent) { + author = authorEl.textContent.trim().replace(/^@/, ''); + } + } + + // Guard: If video was requested, REJECT if resolvedUrl is an image! + if (hasVideoElement && isLikelyImageUrl(resolvedUrl)) { + resolvedUrl = ''; + } + // Guard: If photo was requested, REJECT if resolvedUrl is a video! + if (!hasVideoElement && isLikelyVideoUrl(resolvedUrl)) { + resolvedUrl = ''; + } + + if (resolvedUrl && (resolvedUrl.startsWith('http://') || resolvedUrl.startsWith('https://') || resolvedUrl.startsWith('data:'))) { + const cleanAuthor = author ? author.replace(/^@/, '').trim().slice(0, 13) : ''; + const authorPrefix = cleanAuthor ? ('@' + cleanAuthor) : '@Focusgram'; + const isFinalVideo = hasVideoElement && isVideo && !isLikelyImageUrl(resolvedUrl); + const ext = isFinalVideo ? '.mp4' : '.jpg'; + const filename = `${authorPrefix} - Focusgram${slideIndex > 0 ? ('_' + (slideIndex + 1)) : ''}${ext}`; + triggerNativeDownload(resolvedUrl, filename, null); + return; + } + + // Failed to resolve + const errorMsg = hasVideoElement ? 'Could not extract MP4 video URL for this post' : 'Could not extract photo URL for this post'; + triggerNativeDownload(null, null, errorMsg); + } catch (e) { + console.error('[FocusGram Downloader] handleMediaDownload error:', e); + triggerNativeDownload(null, null, 'Download error: ' + (e.message || e)); + } + } + + async function handleProfilePictureDownload(username, avatarImg) { + try { + // 1. Check harvested profile pictures from GraphQL / API responses + const harvested = (window.wrappedJSObject && window.wrappedJSObject.__fg_profile_pics) || window.__fg_profile_pics; + if (harvested && username && harvested[username.toLowerCase()]) { + const hdUrl = harvested[username.toLowerCase()]; + if (hdUrl && hdUrl.startsWith('http')) { + triggerNativeDownload(hdUrl, username + '_profile_pic_hd.jpg', null); + return; + } + } + + // 2. Attempt official Instagram web_profile_info API for original HD avatar + if (username) { + try { + const res = await fetch('/api/v1/users/web_profile_info/?username=' + encodeURIComponent(username), { + headers: { + 'x-ig-app-id': '936619743392459', + 'x-requested-with': 'XMLHttpRequest' + }, + credentials: 'include' + }); + if (res.ok) { + const json = await res.json(); + const hdUrl = json?.data?.user?.profile_pic_url_hd || json?.data?.user?.profile_pic_url; + if (hdUrl && hdUrl.startsWith('http')) { + triggerNativeDownload(hdUrl, username + '_profile_pic_hd.jpg', null); + return; + } + } + } catch (e) { + console.warn('[FocusGram Downloader] HD profile pic API failed:', e); + } + } + + // 3. Fallback to DOM avatar image — NEVER strip /s150x150/ or regex-mutate signed Meta CDN URLs! + if (avatarImg) { + let url = ''; + if (avatarImg.srcset) { + const parts = avatarImg.srcset.split(',').map(s => s.trim().split(/\s+/)); + url = parts[parts.length - 1]?.[0] || ''; + } + if (!url) url = avatarImg.currentSrc || avatarImg.src || ''; + if (url && url.startsWith('http')) { + const cleanUrl = url.replace(/&/g, '&'); + triggerNativeDownload(cleanUrl, (username || 'instagram') + '_profile_pic.jpg', null); + return; + } + } + + triggerNativeDownload(null, null, 'Could not extract profile picture URL'); + } catch (e) { + triggerNativeDownload(null, null, 'Profile download error: ' + (e.message || e)); + } + } + + function syncProfileDownloadButton() { + if (!isDownloadEnabled()) { + document.querySelectorAll('[data-fg-profile-dl="1"]').forEach(el => el.remove()); + return; + } + + const path = (window.location.pathname || '').replace(/^\/|\/$/g, ''); + if (!path) return; + const segments = path.split('/').filter(Boolean); + if (segments.length === 0) return; + + const RESERVED = ['explore', 'reels', 'reel', 'p', 'direct', 'stories', 'accounts', 'developer', 'about', 'legal', 'settings']; + const usernameCandidate = segments[0]; + if (RESERVED.includes(usernameCandidate.toLowerCase())) return; + const username = usernameCandidate; + + // Find profile avatar in header or main + const avatarImgs = Array.from(document.querySelectorAll('header img, section header img, main header img, [role="main"] header img, img[alt*="profile picture" i], img[alt*="\'s profile" i]')) + .filter(img => { + const r = img.getBoundingClientRect(); + return r.width >= 40 && r.height >= 40; + }); + const avatarImg = avatarImgs[0]; + if (!avatarImg) return; + + // The button or container enclosing the avatar + const avatarBtn = avatarImg.closest('button, [role="button"]') || avatarImg.parentElement; + if (!avatarBtn) return; + + // Wrapper is either the parent of the avatar button, or the button itself + const wrapper = avatarBtn.parentElement || avatarBtn; + if (wrapper.querySelector('[data-fg-profile-dl="1"]')) return; + if (document.querySelector('[data-fg-profile-dl="1"]')) return; + + const pos = window.getComputedStyle(wrapper).position; + if (!pos || pos === 'static') wrapper.style.position = 'relative'; + + const dlBadge = document.createElement('div'); + dlBadge.setAttribute('data-fg-profile-dl', '1'); + dlBadge.setAttribute('role', 'button'); + dlBadge.setAttribute('tabindex', '0'); + dlBadge.setAttribute('aria-label', 'Download Profile Picture'); + dlBadge.title = 'Download Profile Picture'; + dlBadge.style.cssText = [ + 'position:absolute', + 'bottom:-4px', + 'right:-4px', + 'width:36px', + 'height:36px', + 'border-radius:50%', + 'background:linear-gradient(135deg, #00C6FF 0%, #0072FF 100%)', + 'color:#ffffff', + 'border:2.5px solid #18181c', + 'display:flex', + 'align-items:center', + 'justify-content:center', + 'cursor:pointer', + 'z-index:9999', + 'box-shadow:0 3px 10px rgba(0,0,0,0.5)', + 'pointer-events:auto', + 'transition:transform 0.15s ease' + ].join(';'); + + dlBadge.innerHTML = ``; + + let lastTrigger = 0; + const triggerDownload = (e) => { + e.preventDefault(); + e.stopPropagation(); + const now = Date.now(); + if (now - lastTrigger < 1000) return; + lastTrigger = now; + dlBadge.style.transform = 'scale(0.85)'; + setTimeout(() => { dlBadge.style.transform = 'scale(1)'; }, 150); + handleProfilePictureDownload(username, avatarImg); + }; + + dlBadge.addEventListener('click', triggerDownload, true); + dlBadge.addEventListener('touchend', triggerDownload, true); + dlBadge.addEventListener('touchstart', (e) => e.stopPropagation(), true); + dlBadge.addEventListener('pointerdown', (e) => e.stopPropagation(), true); + + wrapper.appendChild(dlBadge); + } + + function findActionRow(post) { + if (!post) return null; + + // 1. Look for Save/Bookmark button + const saveBtn = post.querySelector('button[aria-label*="Save" i], [role="button"][aria-label*="Save" i], button[aria-label*="Bookmark" i], [role="button"][aria-label*="Bookmark" i], svg[aria-label*="Save" i], svg[aria-label*="Bookmark" i], svg polygon, svg path[d^="M20 22"], svg path[d^="M20 21"]'); + if (saveBtn) { + const btn = saveBtn.closest('button, [role="button"]') || saveBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'before' }; + } + } + + // 2. Look for Share button + const shareBtn = post.querySelector('button[aria-label*="Share" i], [role="button"][aria-label*="Share" i], svg[aria-label*="Share" i], svg path[d^="M13.973 20.046"], svg path[d*="M22 3L2 10"]'); + if (shareBtn) { + const btn = shareBtn.closest('button, [role="button"]') || shareBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + // 3. Look for Comment button + const commentBtn = post.querySelector('button[aria-label*="Comment" i], [role="button"][aria-label*="Comment" i], svg[aria-label*="Comment" i]'); + if (commentBtn) { + const btn = commentBtn.closest('button, [role="button"]') || commentBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + // 4. Look for Like button + const likeBtn = post.querySelector('button[aria-label*="Like" i], [role="button"][aria-label*="Like" i], svg[aria-label*="Like" i], svg path[d*="M16.792 3.904"]'); + if (likeBtn) { + const btn = likeBtn.closest('button, [role="button"]') || likeBtn; + if (btn && btn.parentElement) { + return { row: btn.parentElement, anchor: btn, position: 'after' }; + } + } + + // 5. Look for section containing action buttons + const section = post.querySelector('section'); + if (section) { + const buttons = section.querySelectorAll('button, [role="button"]'); + if (buttons.length >= 1) { + const lastBtn = buttons[buttons.length - 1]; + return { row: lastBtn.parentElement || section, anchor: lastBtn, position: 'after' }; + } + } + + return null; + } + + function syncDownloadButtons() { + if (!isDownloadEnabled()) { + document.querySelectorAll('[' + FG_DL_ATTR + '="1"]').forEach(btn => btn.remove()); + return; + } + + const posts = []; + const seen = new Set(); + + // Target individual
elements + document.querySelectorAll('article').forEach(el => { + const r = el.getBoundingClientRect(); + if (r.height > 120 && r.width > 120 && !seen.has(el)) { + seen.add(el); + posts.push(el); + } + }); + + posts.forEach(post => { + if (post.querySelector('[' + FG_DL_ATTR + '="1"]')) return; + + // Extract shortcode from post + const link = post.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); + const href = link ? (link.getAttribute('href') || '') : ''; + const codeMatch = href.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); + const shortcode = codeMatch ? codeMatch[1] : ''; + + const actionInfo = findActionRow(post); + + // Create download button + const dlBtn = document.createElement('div'); + dlBtn.className = 'fg-feed-download-btn'; + dlBtn.setAttribute(FG_DL_ATTR, '1'); + dlBtn.setAttribute('tabindex', '0'); + dlBtn.setAttribute('role', 'button'); + dlBtn.setAttribute('aria-label', 'Download Media'); + dlBtn.title = 'Download Media'; + + let lastTrigger = 0; + let touchStartX = 0; + let touchStartY = 0; + let touchStartTime = 0; + let isHolding = false; + let hasMoved = false; + let holdThresholdPassed = false; + let holdTimer = null; + + const executeDownload = () => { + const now = Date.now(); + if (now - lastTrigger < 1000) return; + lastTrigger = now; + + dlBtn.style.transform = 'scale(0.82)'; + setTimeout(() => { dlBtn.style.transform = 'scale(1)'; }, 150); + + let code = shortcode; + if (!code) { + const l = post.querySelector('a[href*="/p/"], a[href*="/reel/"], a[href*="/reels/"]'); + const h = l ? (l.getAttribute('href') || '') : ''; + const m = h.match(/\/(?:p|reel|reels)\/([A-Za-z0-9_-]+)/); + if (m && m[1] !== 'p' && m[1] !== 'reel' && m[1] !== 'reels') { + code = m[1]; + } + } + + handleMediaDownload(post, code); + }; + + // Touch events: strictly require stationary 30ms hold and abort immediately on any movement/scroll + dlBtn.addEventListener('touchstart', (e) => { + if (isGlobalScrolling || (Date.now() - lastScrollTime < 250)) { + isHolding = false; + hasMoved = true; + return; + } + if (!e.touches || e.touches.length === 0) return; + touchStartX = e.touches[0].clientX; + touchStartY = e.touches[0].clientY; + touchStartTime = Date.now(); + isHolding = true; + hasMoved = false; + holdThresholdPassed = false; + + clearTimeout(holdTimer); + holdTimer = setTimeout(() => { + if (isHolding && !hasMoved && !isGlobalScrolling && (Date.now() - lastScrollTime >= 250)) { + holdThresholdPassed = true; + } + }, 30); + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchmove', (e) => { + if (!isHolding || !e.touches || e.touches.length === 0) return; + const dx = Math.abs(e.touches[0].clientX - touchStartX); + const dy = Math.abs(e.touches[0].clientY - touchStartY); + if (dx > 3 || dy > 3 || isGlobalScrolling) { + hasMoved = true; + isHolding = false; + clearTimeout(holdTimer); + } + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchcancel', () => { + isHolding = false; + hasMoved = true; + clearTimeout(holdTimer); + }, { passive: true, capture: true }); + + dlBtn.addEventListener('touchend', (e) => { + e.preventDefault(); + e.stopPropagation(); + clearTimeout(holdTimer); + const holdDuration = Date.now() - touchStartTime; + + if (isHolding && !hasMoved && (holdThresholdPassed || holdDuration >= 30) && !isGlobalScrolling && (Date.now() - lastScrollTime >= 250)) { + executeDownload(); + } + + isHolding = false; + hasMoved = false; + holdThresholdPassed = false; + }, true); + + // Raw clicks are always cancelled so scroll-release never clicks button + dlBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + }, true); + + if (actionInfo && actionInfo.row && actionInfo.anchor) { + // Inline action bar button matching Instagram styling + dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;transition:transform 0.15s ease;'; + dlBtn.innerHTML = ``; + if (actionInfo.position === 'before') { + actionInfo.row.insertBefore(dlBtn, actionInfo.anchor); + } else { + actionInfo.row.insertBefore(dlBtn, actionInfo.anchor.nextSibling || actionInfo.anchor); + } + } else { + // Fallback: append to section or overlay on post media container (NOT avatar!) + const section = post.querySelector('section'); + if (section) { + dlBtn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;cursor:pointer;padding:8px;margin-right:4px;color:currentColor;opacity:0.9;vertical-align:middle;box-sizing:border-box;pointer-events:auto;'; + dlBtn.innerHTML = ``; + section.appendChild(dlBtn); + } else { + const postMedia = post.querySelector('video')?.parentElement || + Array.from(post.querySelectorAll('img')).find(img => !img.closest('header') && !img.alt.includes('profile') && img.getBoundingClientRect().width > 120)?.parentElement || + post; + const pos = window.getComputedStyle(postMedia).position; + if (!pos || pos === 'static') postMedia.style.position = 'relative'; + + dlBtn.style.cssText = 'position:absolute;bottom:12px;right:12px;z-index:90;width:38px;height:38px;border-radius:50%;background:rgba(0,0,0,0.65);border:1px solid rgba(255,255,255,0.25);color:#fff;display:flex;align-items:center;justify-content:center;cursor:pointer;backdrop-filter:blur(8px);box-shadow:0 2px 8px rgba(0,0,0,0.4);pointer-events:auto;transition:transform 0.15s ease;'; + dlBtn.innerHTML = ``; + postMedia.appendChild(dlBtn); + } + } + }); + } + + function syncReelsDownloadButton() { + // User requested removing in-page Reels download button (native ReelsTopBar handles reel downloads) + document.querySelectorAll('[data-fg-reel-dl="1"]').forEach(el => el.remove()); + } + + async function downloadActiveReel() { + try { + const vh = window.innerHeight; + const videos = Array.from(document.querySelectorAll('video')); + let activeVideo = null; + let minCenterDist = Infinity; + for (const v of videos) { + const r = v.getBoundingClientRect(); + if (r.height > 100 && r.bottom > 0 && r.top < vh) { + const dist = Math.abs((r.top + r.height / 2) - vh / 2); + if (!v.paused) { + activeVideo = v; + break; + } + if (dist < minCenterDist) { + minCenterDist = dist; + activeVideo = v; + } + } + } + if (!activeVideo && videos.length > 0) activeVideo = videos[0]; + + const card = activeVideo ? (activeVideo.closest('[data-fg-reel-card]') || activeVideo.closest('article') || activeVideo.parentElement) : document; + + let code = null; + if (card) { + const reelLink = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]'); + if (reelLink && reelLink.href) { + const m = reelLink.href.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); + if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; + } + } + if (!code) { + const m = window.location.pathname.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/); + if (m && m[1] !== 'reels' && m[1] !== 'reel') code = m[1]; + } + + // Also check fiber for code + if (!code && (activeVideo || card)) { + const fiberData = extractFromFiber(activeVideo) || extractFromFiber(card); + if (fiberData && fiberData.code) { + code = fiberData.code; + } + } + + await handleMediaDownload(card, code, activeVideo); + } catch (e) { + console.error('[FocusGram Downloader] downloadActiveReel error:', e); + triggerNativeDownload(null, null, 'Download error: ' + (e.message || e)); + } + } + + // Attach to global window scope so FocusGramApp ReelsTopBar can invoke it directly + window.__fgDownloadActiveReel = downloadActiveReel; + window.addEventListener('focusgram:download-reel', downloadActiveReel); + + // Periodic heartbeat to maintain buttons across SPA renders + setInterval(() => { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + }, 300); + + // Initial pass + setTimeout(() => { + syncDownloadButtons(); + syncProfileDownloadButton(); + syncReelsDownloadButton(); + }, 200); + +})(); diff --git a/Ported/app/src/main/assets/scripts/native_feel.js b/Ported/app/src/main/assets/scripts/native_feel.js index 34e8b8b..4934ee3 100644 --- a/Ported/app/src/main/assets/scripts/native_feel.js +++ b/Ported/app/src/main/assets/scripts/native_feel.js @@ -12,6 +12,19 @@ 'use strict'; (function () { + function isAuthPage() { + try { + var path = (window.location.pathname || '').toLowerCase(); + if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true; + var c = document.cookie || ''; + if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true; + } catch (_) {} + return false; + } + + if (isAuthPage()) return; // Leave login/signup completely untouched + if (window.__fgNativeFeelInitialized) return; window.__fgNativeFeelInitialized = true; @@ -104,17 +117,112 @@ } } + function enhanceFileInputsForVideoSupport() { + function patchInput(input) { + if (!input || input.tagName !== 'INPUT' || input.type !== 'file') return; + // Never patch profile avatar uploads + const path = window.location.pathname.toLowerCase(); + if (path.includes('/accounts/edit')) return; + + const currentAccept = input.getAttribute('accept') || ''; + if (currentAccept && currentAccept.includes('image') && !currentAccept.includes('video')) { + // Broaden accept attribute to allow video formats (MP4, MOV, WebM, etc.) + input.setAttribute('accept', currentAccept + ',video/mp4,video/quicktime,video/webm,video/*'); + } else if (!currentAccept) { + input.setAttribute('accept', 'image/*,video/*'); + } + } + + // Patch any existing inputs immediately + document.querySelectorAll('input[type="file"]').forEach(patchInput); + + // Intercept pointer/click before it triggers onFilePrompt to guarantee accept attribute is expanded + document.addEventListener('pointerdown', function(e) { + const target = e.target; + if (!target) return; + const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label'); + const fileInput = (target.tagName === 'INPUT' && target.type === 'file') ? target : + (trigger ? (trigger.querySelector('input[type="file"]') || (trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null)) : null); + if (fileInput) { + patchInput(fileInput); + } + }, { capture: true, passive: true }); + + // Observe dynamically created upload dialogs (e.g. DM media buttons, Story camera, New Post) + const observer = new MutationObserver(function(mutations) { + for (let i = 0; i < mutations.length; i++) { + const added = mutations[i].addedNodes; + for (let j = 0; j < added.length; j++) { + const node = added[j]; + if (node.nodeType === 1) { + if (node.tagName === 'INPUT' && node.type === 'file') { + patchInput(node); + } else if (node.querySelectorAll) { + node.querySelectorAll('input[type="file"]').forEach(patchInput); + } + } + } + } + }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + } + + function setupMediaUploadBridge() { + // Ensures user taps on Instagram media upload triggers (Story +, Post +, DMs photo, Profile photo) + // reliably trigger the underlying input[type="file"] element. + document.addEventListener('click', function(e) { + const target = e.target; + if (!target) return; + if (target.tagName === 'INPUT' && target.type === 'file') return; + + const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label'); + if (!trigger) return; + + const fileInput = trigger.querySelector('input[type="file"]') || + (trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null); + + if (fileInput && fileInput !== target) { + try { + fileInput.click(); + } catch (_) {} + } + }, { capture: true, passive: true }); + } + + function preventLandscapeOrientationLock() { + // Blocks web pages / HTML5 video players from forcing device orientation into landscape + // when portrait-only mode is active in FocusGram settings. + try { + if (screen.orientation && typeof screen.orientation.lock === 'function') { + const origLock = screen.orientation.lock.bind(screen.orientation); + screen.orientation.lock = function(orientation) { + const isPortraitOnly = !window.__FOCUSGRAM_CONFIG__ || window.__FOCUSGRAM_CONFIG__.portraitOnly !== false; + if (isPortraitOnly && orientation && typeof orientation === 'string' && orientation.includes('landscape')) { + return Promise.resolve(); + } + return origLock(orientation); + }; + } + } catch (_) {} + } + // Initialize immediately or upon DOM ready injectNativeStyles(); applyScrollSmoothing(); setupKeyboardViewportFix(); + setupMediaUploadBridge(); + enhanceFileInputsForVideoSupport(); + preventLandscapeOrientationLock(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { injectNativeStyles(); applyScrollSmoothing(); + setupMediaUploadBridge(); + enhanceFileInputsForVideoSupport(); + preventLandscapeOrientationLock(); }); } - console.log('[FocusGram] Native Feel & Touch Smoothing active.'); + console.log('[FocusGram] Native Feel, Touch Smoothing, Media Upload Bridge & Video Support active.'); })(); diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt index a816e22..d1d8dce 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt @@ -1,5 +1,6 @@ package com.ujwal.focusgram +import android.content.pm.ActivityInfo import android.os.Build import android.os.Bundle import androidx.fragment.app.FragmentActivity @@ -16,6 +17,7 @@ import com.ujwal.focusgram.ui.FocusGramApp class MainActivity : FragmentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT enableEdgeToEdge() applyHighRefreshRate() setContent { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt index 48e8c7c..cda8ff9 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt @@ -74,10 +74,11 @@ class SettingsRepository(private val context: Context) { isFirstRun = prefs[KEY_IS_FIRST_RUN] ?: false, startupPage = prefs[KEY_STARTUP_PAGE] ?: "home", - videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: true, + videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: false, sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true, notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false, notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false, + portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true, ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false, ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true, @@ -154,6 +155,7 @@ class SettingsRepository(private val context: Context) { suspend fun setSanitizeLinks(enabled: Boolean) = setBoolean(KEY_SANITIZE_LINKS, enabled) suspend fun setNotifySessionEnd(enabled: Boolean) = setBoolean(KEY_NOTIFY_SESSION_END, enabled) suspend fun setNotifyPersistent(enabled: Boolean) = setBoolean(KEY_NOTIFY_PERSISTENT, enabled) + suspend fun setPortraitOnly(enabled: Boolean) = setBoolean(KEY_PORTRAIT_ONLY, enabled) suspend fun addInstaMinutes(minutes: Int) { context.dataStore.edit { prefs -> @@ -286,6 +288,7 @@ class SettingsRepository(private val context: Context) { val KEY_SANITIZE_LINKS = booleanPreferencesKey("set_sanitize_links") val KEY_NOTIFY_SESSION_END = booleanPreferencesKey("set_notify_session_end") val KEY_NOTIFY_PERSISTENT = booleanPreferencesKey("set_notify_persistent") + val KEY_PORTRAIT_ONLY = booleanPreferencesKey("portrait_only") val KEY_GHOST_MODE_ENABLED = booleanPreferencesKey("ghost_mode_enabled") val KEY_GHOST_HIDE_STORY_SEEN = booleanPreferencesKey("ghost_hide_story_seen") diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt index e01588e..cd834e4 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt @@ -34,6 +34,9 @@ class ExtensionManager(private val runtime: GeckoRuntime) { var onReelViewed: ((url: String, title: String, author: String, thumbnail: String) -> Unit)? = null var onReelMeta: ((url: String, title: String, author: String) -> Unit)? = null var onDownloadRequested: ((url: String, filename: String) -> Unit)? = null + var onDownloadError: ((error: String) -> Unit)? = null + var onDownloadStarted: ((filename: String) -> Unit)? = null + var onAuthStateChanged: ((isAuthenticated: Boolean, isAuthPage: Boolean) -> Unit)? = null /** * Installs and activates built-in content blocking and FocusGram controller from APK assets. @@ -42,9 +45,16 @@ class ExtensionManager(private val runtime: GeckoRuntime) { contentBlockerExtension?.let { return Result.success(it) } return try { Log.i(TAG, "Mounting built-in Content Blocker WebExtension from assets...") - val extension = runtime.webExtensionController - .ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID) - .await() + val extension = try { + runtime.webExtensionController + .installBuiltIn(CONTENT_BLOCKER_ASSET_URI) + .await() + } catch (e: Exception) { + Log.w(TAG, "installBuiltIn fallback to ensureBuiltIn: ${e.message}") + runtime.webExtensionController + .ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID) + .await() + } if (extension != null) { try { @@ -177,12 +187,32 @@ class ExtensionManager(private val runtime: GeckoRuntime) { } } } + "downloadStarted" -> { + val filename = json.optString("filename") + Log.d(TAG, "Received downloadStarted from web: filename=$filename") + mainHandler.post { + onDownloadStarted?.invoke(filename) + } + } "downloadMedia" -> { val mediaUrl = json.optString("url") val filename = json.optString("filename") - Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename") + val error = json.optString("error") + Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename, error=$error") mainHandler.post { - onDownloadRequested?.invoke(mediaUrl, filename) + if (mediaUrl.isNotBlank()) { + onDownloadRequested?.invoke(mediaUrl, filename) + } else if (error.isNotBlank()) { + onDownloadError?.invoke(error) + } + } + } + "authStateChanged" -> { + val isAuthenticated = json.optBoolean("isAuthenticated", true) + val isAuthPage = json.optBoolean("isAuthPage", false) + Log.d(TAG, "Received authStateChanged from web: isAuthenticated=$isAuthenticated, isAuthPage=$isAuthPage") + mainHandler.post { + onAuthStateChanged?.invoke(isAuthenticated, isAuthPage) } } "getSettings" -> { @@ -219,6 +249,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) { put("grayscale", settings.grayscaleEnabled) put("minimalMode", settings.minimalModeEnabled) put("videoDownloadEnabled", settings.videoDownloadEnabled) + put("portraitOnly", settings.portraitOnly) put("ghostModeEnabled", settings.ghostModeEnabled) put("ghostHideStorySeen", settings.ghostHideStorySeen) put("ghostHideDirectSeen", settings.ghostHideDirectSeen) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt index 61186d8..ba57fa7 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt @@ -38,6 +38,23 @@ class FocusGramNavigationDelegate( return GeckoResult.fromValue(AllowOrDeny.DENY) } + // 2b. Intercept native media download requests from in-page client scripts + if (scheme == "focusgram-download") { + val mediaUrl = uri.getQueryParameter("url") ?: "" + val filename = uri.getQueryParameter("filename") ?: "" + val error = uri.getQueryParameter("error") ?: "" + Log.d(TAG, "Intercepted focusgram-download: url=$mediaUrl, filename=$filename, error=$error") + val mainHandler = android.os.Handler(android.os.Looper.getMainLooper()) + mainHandler.post { + if (mediaUrl.isNotBlank()) { + com.ujwal.focusgram.core.utils.MediaDownloadHelper.download(context, mediaUrl, filename) + } else if (error.isNotBlank()) { + android.widget.Toast.makeText(context, error, android.widget.Toast.LENGTH_SHORT).show() + } + } + return GeckoResult.fromValue(AllowOrDeny.DENY) + } + // 3. Intercept navigation trap to suggested accounts (/explore/people) from Favorites val path = uri.path?.lowercase() ?: "" if (path.contains("/explore/people")) { @@ -82,6 +99,7 @@ class FocusGramNavigationDelegate( hasUserGesture: Boolean ) { url?.let { + if (it.startsWith("focusgram-download:")) return if (it.lowercase().contains("/explore/people")) { Log.i(TAG, "Location redirected away from /explore/people to home") sessionManager.loadUrl("https://www.instagram.com/") diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt index c8ee433..f9eed62 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt @@ -30,7 +30,18 @@ class FocusGramPermissionDelegate( ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED } - if (ungranted.isEmpty()) { + // On Android 13+ (API 33+), READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are deprecated + // and automatically denied by the OS. System file pickers do not require storage permissions. + val actionableUngranted = if (android.os.Build.VERSION.SDK_INT >= 33) { + ungranted.filter { + it != android.Manifest.permission.READ_EXTERNAL_STORAGE && + it != android.Manifest.permission.WRITE_EXTERNAL_STORAGE + } + } else { + ungranted + } + + if (actionableUngranted.isEmpty()) { callback.grant() return } @@ -38,12 +49,14 @@ class FocusGramPermissionDelegate( if (onAndroidPermissionRequest != null) { Handler(Looper.getMainLooper()).post { try { - onAndroidPermissionRequest.invoke(ungranted.toTypedArray()) { allGranted -> - if (allGranted) callback.grant() else callback.reject() + onAndroidPermissionRequest.invoke(actionableUngranted.toTypedArray()) { granted -> + // Always grant to GeckoView so it proceeds to onFilePrompt() where + // native ActivityResultContracts (Photo Picker / SAF) handle file access securely. + callback.grant() } } catch (e: Exception) { Log.e(TAG, "Error requesting Android permissions: ${e.message}", e) - callback.reject() + callback.grant() } } } else { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt index 0c9874b..35a263d 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPromptDelegate.kt @@ -24,6 +24,7 @@ class FocusGramPromptDelegate( fun onPickFiles( mimeTypes: Array, allowMultiple: Boolean, + capture: Int, onResult: (List) -> Unit ) } @@ -34,12 +35,15 @@ class FocusGramPromptDelegate( ): GeckoResult { val handler = filePickerHandler if (handler == null) { - Log.d(TAG, "No FilePickerHandler attached. Dismissing file prompt.") + Log.w(TAG, "No FilePickerHandler attached. Dismissing file prompt.") return GeckoResult.fromValue(prompt.dismiss()) } val mimeTypes = prompt.mimeTypes ?: arrayOf("*/*") val isMultiple = prompt.type == GeckoSession.PromptDelegate.FilePrompt.Type.MULTIPLE + val capture = prompt.capture + + Log.i(TAG, "onFilePrompt triggered: type=${prompt.type}, isMultiple=$isMultiple, capture=$capture, mimeTypes=${mimeTypes.joinToString()}") val result = GeckoResult() @@ -47,13 +51,16 @@ class FocusGramPromptDelegate( // ActivityResultLauncher.launch() MUST be called on the main Looper thread. Handler(Looper.getMainLooper()).post { try { - handler.onPickFiles(mimeTypes, isMultiple) { uris -> + handler.onPickFiles(mimeTypes, isMultiple, capture) { uris -> try { if (uris.isEmpty()) { + Log.i(TAG, "File picker dismissed or canceled by user.") result.complete(prompt.dismiss()) } else if (isMultiple) { + Log.i(TAG, "Confirming multiple files (${uris.size}) to GeckoView: ${uris.joinToString()}") result.complete(prompt.confirm(context, uris.toTypedArray())) } else { + Log.i(TAG, "Confirming single file to GeckoView: ${uris.first()}") result.complete(prompt.confirm(context, uris.first())) } } catch (e: Exception) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt index 9a587ab..d010c77 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt @@ -44,6 +44,21 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { val instagramTheme: StateFlow = _instagramTheme.asStateFlow() private var isOpen = false + var isAuthenticated: Boolean = false + private set + + fun setAuthenticated(auth: Boolean) { + val changed = isAuthenticated != auth + isAuthenticated = auth + if (auth && changed) { + val url = _currentUrl.value.lowercase() + val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup") + if (!isAuth) { + injectBottomBarTrimmer() + userScriptManager?.injectAll(session, currentSettings) + } + } + } init { val settings = GeckoSessionSettings.Builder() @@ -51,6 +66,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { .suspendMediaWhenInactive(true) .allowJavascript(true) .userAgentMode(GeckoSessionSettings.USER_AGENT_MODE_MOBILE) + .userAgentOverride(IOS_USER_AGENT) .usePrivateMode(false) // Retain Instagram authentication cookies locally .build() @@ -62,9 +78,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { session.progressDelegate = object : GeckoSession.ProgressDelegate { override fun onPageStart(session: GeckoSession, url: String) { Log.d(TAG, "Page started loading: $url") - _isLoading.value = true - _currentUrl.value = url - _progress.value = 10 + if (!url.startsWith("focusgram-download:") && !url.startsWith("javascript:")) { + _isLoading.value = true + _currentUrl.value = url + _progress.value = 10 + } } override fun onPageStop(session: GeckoSession, success: Boolean) { @@ -72,8 +90,12 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { _isLoading.value = false _progress.value = 100 if (success) { - injectBottomBarTrimmer() - userScriptManager?.injectAll(session, currentSettings) + val url = _currentUrl.value.lowercase() + val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup") + if (isAuthenticated && !isAuth) { + injectBottomBarTrimmer() + userScriptManager?.injectAll(session, currentSettings) + } } } @@ -145,7 +167,9 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun updateSettings(settings: FocusSettings) { currentSettings = settings - userScriptManager?.injectConfig(session, settings) + if (isAuthenticated) { + userScriptManager?.injectConfig(session, settings) + } } fun updateCurrentUrl(url: String) { @@ -275,7 +299,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) { fun injectBottomBarTrimmer() { val js = """javascript:(function() { try { - if (window.location.pathname.startsWith('/direct')) return; + var p = (window.location.pathname || '').toLowerCase(); + if (p.startsWith('/direct') || p.startsWith('/accounts') || p.startsWith('/login') || p.startsWith('/signup')) return; + var c = document.cookie || ''; + if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return; + if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return; document.querySelectorAll('a[href="/"], a[href="/?variant=home"], svg[aria-label="Home"], a[href*="/explore/"]').forEach(function(target) { var el = target.closest('a') || target; while (el && el !== document.body && el !== document.documentElement) { diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt index c7ac310..663ac36 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt @@ -50,7 +50,8 @@ class UserScriptManager(private val context: Context) { blockAutoplay: ${settings.noAutoplay}, grayscale: ${settings.grayscaleEnabled}, minimalMode: ${settings.minimalModeEnabled}, - videoDownloadEnabled: ${settings.videoDownloadEnabled} + videoDownloadEnabled: ${settings.videoDownloadEnabled}, + portraitOnly: ${settings.portraitOnly} }; window.__FOCUSGRAM_GHOST_CONFIG__ = { enabled: ${settings.ghostModeEnabled}, @@ -121,6 +122,7 @@ class UserScriptManager(private val context: Context) { bundle.append(loadScript("autoplay_blocker.js")).append("\n") bundle.append(loadScript("grayscale.js")).append("\n") bundle.append(loadScript("native_feel.js")).append("\n") + bundle.append(loadScript("media_downloader.js")).append("\n") evaluate(session, bundle.toString()) injectConfig(session, settings) } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt index 69b0168..85d7f01 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt @@ -58,10 +58,11 @@ data class FocusSettings( // Extras & Notifications val startupPage: String = "home", - val videoDownloadEnabled: Boolean = true, + val videoDownloadEnabled: Boolean = false, val sanitizeLinks: Boolean = true, val notifySessionEnd: Boolean = false, val notifyPersistent: Boolean = false, + val portraitOnly: Boolean = true, // Ghost Mode (Privacy & Stealth) val ghostModeEnabled: Boolean = false, diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt index dc17322..8e14377 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/CupertinoWheelPicker.kt @@ -8,6 +8,7 @@ import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.view.HapticFeedbackConstants +import com.ujwal.focusgram.core.utils.HapticHelper import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -383,41 +384,10 @@ private fun getVibrator(context: Context): Vibrator? { /** * Perform crisp haptic tick even when user has system "touch feedback" disabled. - * Uses both View ignore flags, and falls back to physical Vibrator with USAGE_ALARM. + * Uses direct physical vibration (35ms pulse) to ensure tactile feedback on all devices + * (both ERM and LRA motors, like MX Player), without relying on unsupported predefined effects. */ private fun performWheelHaptic(context: Context, view: android.view.View) { - try { - // 1. Try View haptics with ignore flags - val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING - } else { - @Suppress("DEPRECATION") - HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING - } - val performed = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK, flags) - if (performed) return - - // 2. Hardware Vibrator fallback with USAGE_ALARM to bypass system touch vibration disablement - val vibrator = getVibrator(context) ?: return - if (!vibrator.hasVibrator()) return - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val attributes = VibrationAttributes.Builder() - .setUsage(VibrationAttributes.USAGE_ALARM) - .build() - val effect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK) - vibrator.vibrate(effect, attributes) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val audioAttributes = AudioAttributes.Builder() - .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(AudioAttributes.USAGE_ALARM) - .build() - val effect = VibrationEffect.createOneShot(12L, VibrationEffect.DEFAULT_AMPLITUDE) - @Suppress("DEPRECATION") - vibrator.vibrate(effect, audioAttributes) - } else { - @Suppress("DEPRECATION") - vibrator.vibrate(10L) - } - } catch (_: Exception) {} + HapticHelper.performWheelTick(context, view) } + diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt new file mode 100644 index 0000000..12632ba --- /dev/null +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt @@ -0,0 +1,173 @@ +package com.ujwal.focusgram.core.utils + +import android.content.Context +import android.os.Build +import android.os.SystemClock +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import android.util.Log +import android.view.HapticFeedbackConstants +import android.view.View + +/** + * HapticHelper + * Universal haptic feedback and vibration utility for FocusGram. + * + * Guarantees physical vibration across all hardware motors (ERM & LRA), + * regardless of whether system "Touch feedback" is disabled in user Settings. + * Emulates the proven direct-hardware approach of media apps like MX Player + * by pulsing the physical vibrator directly with sufficient duration (35ms) + * to overcome motor inertia without relying on unsupported predefined effects. + */ +object HapticHelper { + + private const val TAG = "HapticHelper" + private var lastWheelTickTime = 0L + + /** + * Retrieves the system Vibrator service using modern, non-deprecated APIs. + */ + fun getVibrator(context: Context): Vibrator? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val vibratorManager = context.getSystemService(VibratorManager::class.java) + vibratorManager?.defaultVibrator + } else { + context.getSystemService(Vibrator::class.java) + } + } + + /** + * Performs a crisp, responsive drum-wheel scroll tick. + * Direct hardware pulse of 35ms ensures ERM motors overcome inertia and + * rotate to produce an unmistakable tactile click, while LRA motors give a crisp bump. + * Debounced at 35ms to ensure smooth performance during rapid flings. + */ + fun performWheelTick(context: Context, view: View? = null) { + val now = SystemClock.uptimeMillis() + if (now - lastWheelTickTime < 35L) return + lastWheelTickTime = now + + val vibrator = getVibrator(context) + if (vibrator != null && vibrator.hasVibrator()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot(35L, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(35L) + } + } catch (e: Exception) { + Log.w(TAG, "Hardware wheel tick failed: ${e.message}") + } + } + + // Companion View haptic for devices that integrate with platform accessibility + try { + view?.performHapticFeedback( + HapticFeedbackConstants.CLOCK_TICK, + HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + ) + } catch (_: Exception) {} + } + + /** + * Performs an alert vibration pattern when a focus session timer expires, + * daily limit is exhausted, or the lock screen activates. + * Pattern: Buzz 280ms -> Pause 140ms -> Buzz 280ms. + */ + fun performSessionCompletionAlert(context: Context) { + val vibrator = getVibrator(context) ?: return + if (!vibrator.hasVibrator()) return + + try { + val pattern = longArrayOf(0, 280, 140, 280) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createWaveform(pattern, -1) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(pattern, -1) + } + Log.d(TAG, "Session completion vibration triggered successfully") + } catch (e: Exception) { + Log.e(TAG, "Failed to vibrate for session completion: ${e.message}", e) + } + } + + /** + * Performs a tactile click pulse (25ms) when a tab or button is tapped. + * Uses direct motor pulse so devices with ERM or LRA motors vibrate regardless of + * system touch-feedback toggles, matching media apps like MX Player. + */ + fun performClick(context: Context, view: View? = null) { + val vibrator = getVibrator(context) + if (vibrator != null && vibrator.hasVibrator()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot(25L, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(25L) + } + } catch (e: Exception) { + Log.w(TAG, "Hardware click vibration failed: ${e.message}") + } + } + try { + view?.performHapticFeedback( + HapticFeedbackConstants.VIRTUAL_KEY, + HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + ) + } catch (_: Exception) {} + } + + /** + * Performs a tactile long-press pulse (60ms) when long-pressing an element. + */ + fun performLongPress(context: Context, view: View? = null) { + val vibrator = getVibrator(context) + if (vibrator != null && vibrator.hasVibrator()) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(60L) + } + } catch (e: Exception) { + Log.w(TAG, "Hardware long-press vibration failed: ${e.message}") + } + } + try { + view?.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING + ) + } catch (_: Exception) {} + } + + /** + * Performs a single medium confirmation pulse (60ms) when completing + * a mindful breath gate or cooldown waiting period. + */ + fun performSuccessPulse(context: Context) { + val vibrator = getVibrator(context) ?: return + if (!vibrator.hasVibrator()) return + + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator.vibrate(effect) + } else { + @Suppress("DEPRECATION") + vibrator.vibrate(60L) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to vibrate success pulse: ${e.message}", e) + } + } +} diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt index 8841f05..14c813b 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt @@ -10,9 +10,25 @@ import android.provider.MediaStore import android.util.Base64 import android.util.Log import android.widget.Toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import java.io.File import java.io.FileOutputStream +data class DownloadProgressState( + val isDownloading: Boolean = false, + val progress: Float = 0f, // 0f to 1f + val filename: String = "", + val isCompleted: Boolean = false, + val isFailed: Boolean = false +) + /** * MediaDownloadHelper * Native client-side media downloader using Android System DownloadManager & MediaStore. @@ -21,20 +37,154 @@ import java.io.FileOutputStream object MediaDownloadHelper { private const val TAG = "MediaDownloadHelper" + private val _downloadProgressFlow = MutableStateFlow(DownloadProgressState()) + val downloadProgressFlow: StateFlow = _downloadProgressFlow.asStateFlow() + + private val helperScope = CoroutineScope(Dispatchers.IO) + private var progressJob: Job? = null + + private var lastDownloadUrl: String = "" + private var lastDownloadTimestamp: Long = 0L + + /** + * Determines whether media is a video based on explicit URL and filename markers. + * Note: NEVER treat "/v/" in CDN URLs as video because all Meta CDN photos contain "/v/". + */ + fun detectIsVideo(url: String, filename: String = ""): Boolean { + val lowerUrl = url.lowercase() + val lowerFile = filename.lowercase() + if (lowerUrl.contains(".jpg") || lowerUrl.contains(".jpeg") || lowerUrl.contains(".png") || + lowerUrl.contains(".webp") || lowerUrl.contains("dst-jpg") || lowerUrl.contains("dst-png") || + lowerUrl.contains("dst-webp") || lowerUrl.startsWith("data:image") || + lowerFile.endsWith(".jpg") || lowerFile.endsWith(".jpeg") || + lowerFile.endsWith(".png") || lowerFile.endsWith(".webp") + ) { + return false + } + if (lowerUrl.contains(".mp4") || lowerUrl.contains(".webm") || lowerUrl.contains("video_dashinit") || + lowerUrl.contains("/v/t50.") || lowerUrl.contains("/v/t16.") || + lowerUrl.startsWith("data:video") || lowerFile.endsWith(".mp4") + ) { + return true + } + return false + } + + /** + * Formats target filename according to FocusGram specification: + * Author name (first 10-13 characters) followed by ' - Focusgram.' + * E.g. '@ujwal - Focusgram.mp4' or '@ujwal - Focusgram.jpg'. + */ + fun formatTargetFilename(rawFilename: String, isVideo: Boolean): String { + val extension = if (isVideo) "mp4" else "jpg" + var clean = rawFilename.trim() + + // Strip known extension if present + if (clean.endsWith(".mp4", ignoreCase = true) || clean.endsWith(".jpg", ignoreCase = true) || + clean.endsWith(".jpeg", ignoreCase = true) || clean.endsWith(".png", ignoreCase = true) + ) { + clean = clean.substringBeforeLast('.') + } + + // Handle slide suffix (e.g. _2 for carousel slides) + var slideSuffix = "" + val slideMatch = Regex("_(\\d+)$").find(clean) + if (slideMatch != null) { + slideSuffix = "_" + slideMatch.groupValues[1] + clean = clean.removeSuffix(slideSuffix) + } + + // Extract author prefix + val authorName = when { + clean.startsWith("@") -> clean.removePrefix("@").substringBefore(" - ").substringBefore('_').trim() + clean.contains(" - Focusgram", ignoreCase = true) -> { + val idx = clean.indexOf(" - Focusgram", ignoreCase = true) + if (idx != -1) clean.substring(0, idx).trim().removePrefix("@") else "" + } + clean.startsWith("instagram_", ignoreCase = true) -> "" + clean.startsWith("Focusgram", ignoreCase = true) -> "" + clean.isNotBlank() -> clean.substringBefore('_').trim().removePrefix("@") + else -> "" + } + + val sanitizedAuthor = authorName + .replace(Regex("[^a-zA-Z0-9._-]"), "") + .take(13) + .ifBlank { "Focusgram" } + + val prefix = if (sanitizedAuthor.equals("Focusgram", ignoreCase = true)) "@Focusgram" else "@$sanitizedAuthor" + return "$prefix - Focusgram$slideSuffix.$extension" + } + + /** + * Starts visual perimeter progress in the bottom nav bar while URL is resolving. + * Starts at 0.05f without fake timer loops so visual pace reflects actual network download. + */ + fun startIndeterminateProgress(filename: String = "") { + progressJob?.cancel() + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = 0.05f, + filename = filename, + isCompleted = false, + isFailed = false + ) + } + + /** + * Reverses progress bar from current position back to start in red/dark-red and cancels animation. + */ + fun triggerDownloadFailure(context: Context, message: String = "Download failed") { + progressJob?.cancel() + val startProgress = _downloadProgressFlow.value.progress.coerceAtLeast(0.12f) + progressJob = helperScope.launch { + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = startProgress, + filename = "", + isCompleted = false, + isFailed = true + ) + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + val steps = 20 + for (i in steps downTo 0) { + delay(20L) // 20 * 20ms = ~400ms smooth reverse animation + _downloadProgressFlow.value = _downloadProgressFlow.value.copy( + progress = startProgress * (i.toFloat() / steps) + ) + } + delay(60L) + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 0f, + filename = "", + isCompleted = false, + isFailed = false + ) + } + } + fun download(context: Context, url: String, filename: String = "") { if (url.isBlank()) { - Toast.makeText(context, "No media URL found to download", Toast.LENGTH_SHORT).show() + triggerDownloadFailure(context, "No media URL found to download") return } + val now = System.currentTimeMillis() + if (url == lastDownloadUrl && (now - lastDownloadTimestamp) < 1500L) { + Log.d(TAG, "Ignoring duplicate download trigger within debounce window") + return + } + lastDownloadUrl = url + lastDownloadTimestamp = now + try { - val isVideo = url.contains(".mp4") || url.contains("video") || url.contains("/v/") || url.startsWith("data:video") - val extension = if (isVideo) "mp4" else "jpg" - val targetFilename = if (filename.isNotBlank()) { - if (filename.contains(".")) filename else "$filename.$extension" - } else { - "FocusGram_${System.currentTimeMillis()}.$extension" - } + val isVideo = detectIsVideo(url, filename) + val targetFilename = formatTargetFilename(filename, isVideo) + + progressJob?.cancel() if (url.startsWith("data:")) { val commaIndex = url.indexOf(',') @@ -42,8 +192,18 @@ object MediaDownloadHelper { val base64Data = url.substring(commaIndex + 1) val bytes = Base64.decode(base64Data, Base64.DEFAULT) saveBytesToDownloads(context, bytes, targetFilename, isVideo) - Toast.makeText(context, "Saved to Downloads: $targetFilename", Toast.LENGTH_SHORT).show() Log.i(TAG, "Saved data URI to Downloads: $targetFilename") + + progressJob = helperScope.launch { + _downloadProgressFlow.value = DownloadProgressState(isDownloading = true, progress = 0.5f, filename = targetFilename) + delay(150L) + _downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 1.0f, filename = targetFilename, isCompleted = true) + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show() + } + delay(1200L) + _downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f, filename = "", isCompleted = false) + } return } } @@ -63,27 +223,216 @@ object MediaDownloadHelper { val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager if (manager != null) { - manager.enqueue(request) - Toast.makeText(context, "Download started: $targetFilename", Toast.LENGTH_SHORT).show() - Log.i(TAG, "Download enqueued for $targetFilename from $url") + val downloadId = try { + manager.enqueue(request) + } catch (e: Exception) { + Log.w(TAG, "DownloadManager.enqueue failed, falling back to direct HTTP: ${e.message}") + downloadDirectHttp(context, url, targetFilename, isVideo) + return + } + + Log.i(TAG, "Download enqueued for $targetFilename (id=$downloadId) from $url") + + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = 0.08f, + filename = targetFilename, + isCompleted = false, + isFailed = false + ) + + progressJob = helperScope.launch { + var lastProgress = 0.08f + var isDone = false + val startTime = System.currentTimeMillis() + + while (!isDone && (System.currentTimeMillis() - startTime < 60000L)) { + delay(120L) + try { + val query = DownloadManager.Query().setFilterById(downloadId) + val cursor = manager.query(query) + if (cursor != null && cursor.moveToFirst()) { + val bytesCol = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val totalCol = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + val statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val reasonCol = cursor.getColumnIndex(DownloadManager.COLUMN_REASON) + + val bytesDownloaded = if (bytesCol != -1) cursor.getLong(bytesCol) else 0L + val totalBytes = if (totalCol != -1) cursor.getLong(totalCol) else -1L + val status = if (statusCol != -1) cursor.getInt(statusCol) else -1 + val reason = if (reasonCol != -1) cursor.getInt(reasonCol) else -1 + cursor.close() + + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + isDone = true + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 1.0f, + filename = targetFilename, + isCompleted = true, + isFailed = false + ) + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show() + } + delay(1200L) + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 0f, + filename = "", + isCompleted = false + ) + } + DownloadManager.STATUS_FAILED -> { + isDone = true + Log.w(TAG, "DownloadManager failed with reason $reason, retrying via direct HTTP") + downloadDirectHttp(context, url, targetFilename, isVideo) + } + else -> { + val computed = if (totalBytes > 0) { + (bytesDownloaded.toFloat() / totalBytes.toFloat()).coerceIn(0.08f, 0.98f) + } else { + (lastProgress + 0.02f).coerceAtMost(0.85f) + } + lastProgress = computed + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = computed, + filename = targetFilename, + isCompleted = false, + isFailed = false + ) + } + } + } else { + cursor?.close() + if (lastProgress >= 0.7f) { + isDone = true + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 1.0f, + filename = targetFilename, + isCompleted = true, + isFailed = false + ) + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show() + } + delay(1200L) + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 0f, + filename = "", + isCompleted = false + ) + } + } + } catch (e: Exception) { + Log.w(TAG, "Error checking download progress: ${e.message}") + } + } + } } else { - Toast.makeText(context, "Download service unavailable", Toast.LENGTH_SHORT).show() + Log.w(TAG, "DownloadManager unavailable, falling back to direct HTTP") + downloadDirectHttp(context, url, targetFilename, isVideo) } } catch (e: Exception) { Log.e(TAG, "Failed to download media: ${e.message}", e) - Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_SHORT).show() + triggerDownloadFailure(context, "Download failed: ${e.message ?: "Unknown error"}") + } + } + + private fun downloadDirectHttp(context: Context, url: String, targetFilename: String, isVideo: Boolean) { + helperScope.launch { + try { + Log.i(TAG, "Starting direct HTTP download for $targetFilename from $url") + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = 0.08f, + filename = targetFilename, + isCompleted = false, + isFailed = false + ) + val connection = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply { + requestMethod = "GET" + connectTimeout = 15000 + readTimeout = 30000 + setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36") + setRequestProperty("Referer", "https://www.instagram.com/") + setRequestProperty("Accept", "*/*") + instanceFollowRedirects = true + } + connection.connect() + val code = connection.responseCode + if (code in 200..299) { + val contentLength = connection.contentLengthLong + val bytesOut = java.io.ByteArrayOutputStream() + connection.inputStream.use { input -> + val buffer = ByteArray(8192) + var read: Int + var downloaded = 0L + while (input.read(buffer).also { read = it } != -1) { + bytesOut.write(buffer, 0, read) + downloaded += read + if (contentLength > 0) { + val prog = (downloaded.toFloat() / contentLength.toFloat()).coerceIn(0.08f, 0.98f) + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = true, + progress = prog, + filename = targetFilename, + isCompleted = false, + isFailed = false + ) + } + } + } + val data = bytesOut.toByteArray() + saveBytesToDownloads(context, data, targetFilename, isVideo) + _downloadProgressFlow.value = DownloadProgressState( + isDownloading = false, + progress = 1.0f, + filename = targetFilename, + isCompleted = true, + isFailed = false + ) + android.os.Handler(android.os.Looper.getMainLooper()).post { + Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show() + } + delay(1200L) + _downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f) + } else { + Log.e(TAG, "Direct HTTP download returned status $code") + triggerDownloadFailure(context, "Download failed (HTTP $code)") + } + } catch (e: Exception) { + Log.e(TAG, "Direct HTTP download error: ${e.message}", e) + triggerDownloadFailure(context, "Download failed: ${e.message ?: "Network error"}") + } } } private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) { - val mimeType = if (isVideo) "video/mp4" else "image/jpeg" + // Inspect magic bytes to verify whether bytes are an image or a video + val isActuallyImage = bytes.size >= 4 && ( + (bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) || // JPEG + (bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) // PNG + ) + val resolvedIsVideo = if (isActuallyImage) false else isVideo + val resolvedFilename = if (isActuallyImage && filename.endsWith(".mp4", ignoreCase = true)) { + filename.substringBeforeLast('.') + ".jpg" + } else { + filename + } + val mimeType = if (resolvedIsVideo) "video/mp4" else "image/jpeg" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val values = ContentValues().apply { - put(MediaStore.MediaColumns.DISPLAY_NAME, filename) + put(MediaStore.MediaColumns.DISPLAY_NAME, resolvedFilename) put(MediaStore.MediaColumns.MIME_TYPE, mimeType) put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) } - val collection = if (isVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI + val collection = if (resolvedIsVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI val itemUri = context.contentResolver.insert(collection, values) if (itemUri != null) { context.contentResolver.openOutputStream(itemUri)?.use { out -> @@ -93,7 +442,7 @@ object MediaDownloadHelper { } else { @Suppress("DEPRECATION") val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) - val file = File(downloadsDir, filename) + val file = File(downloadsDir, resolvedFilename) FileOutputStream(file).use { out -> out.write(bytes) } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt index 4634ad6..f105676 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt @@ -5,6 +5,7 @@ import android.view.HapticFeedbackConstants import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState @@ -21,6 +22,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.shadow import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -42,6 +44,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -59,11 +62,13 @@ import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import com.ujwal.focusgram.core.model.FocusSettings import com.ujwal.focusgram.core.model.IslandMode +import com.ujwal.focusgram.core.utils.HapticHelper import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -102,6 +107,12 @@ fun NativeBottomNavBar( instagramTheme: String = "auto", isWebInputFocused: Boolean = false, isModalActive: Boolean = false, + downloadProgress: Float = 0f, + isDownloading: Boolean = false, + isDownloadCompleted: Boolean = false, + isDownloadFailed: Boolean = false, + isAuthenticated: Boolean = false, + isAuthPage: Boolean = false, onNavClick: ((String) -> Unit)? = null, onNavigate: (String) -> Unit, onOpenProfile: () -> Unit, @@ -110,6 +121,7 @@ fun NativeBottomNavBar( onRefreshReels: () -> Unit = {}, modifier: Modifier = Modifier ) { + val context = LocalContext.current val view = LocalView.current val density = LocalDensity.current val scope = rememberCoroutineScope() @@ -137,7 +149,8 @@ fun NativeBottomNavBar( } else { isInDirect } - val isVisible = !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN + val isAuth = isAuthPage || !isAuthenticated || lowerUrl.contains("/accounts") || lowerUrl.contains("/login") || lowerUrl.contains("/signup") + val isVisible = !isAuth && !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN // Real-time animated color palette matching Instagram's web theme val isSystemDark = isSystemInDarkTheme() @@ -148,18 +161,35 @@ fun NativeBottomNavBar( } val islandBackground by animateColorAsState( targetValue = if (islandMode == IslandMode.FLOATING) { - if (isLight) Color(0x9CFFFFFF) else Color(0x8B101014) + if (isLight) Color(0xB0FFFFFF) else Color(0x9E101014) } else { - if (isLight) Color(0x9EFFFFFF) else Color(0x9B141418) + if (isLight) Color(0xB2FFFFFF) else Color(0xAE141418) }, animationSpec = tween(300), label = "islandBackground" ) val borderColor by animateColorAsState( - targetValue = if (isLight) Color(0x2B000000) else Color(0x4DFFFFFF), + targetValue = if (isLight) Color(0x35000000) else Color(0x57FFFFFF), animationSpec = tween(300), label = "borderColor" ) + val glassBorderBrush = remember(isLight) { + if (isLight) { + Brush.verticalGradient( + colors = listOf( + Color(0x3E000000), // Top specular rim (+4% glassy) + Color(0x20000000) // Bottom diffused rim + ) + ) + } else { + Brush.verticalGradient( + colors = listOf( + Color(0x60FFFFFF), // Top specular glass rim (+4% glassy) + Color(0x36FFFFFF) // Bottom subtle glass rim + ) + ) + } + } val selectedTabBackground by animateColorAsState( targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF), animationSpec = tween(300), @@ -183,18 +213,80 @@ fun NativeBottomNavBar( // Long press perimeter progress animation state val longPressProgressAnim = remember { Animatable(0f) } - // Determine currently active tab index - val selectedTabIndex = activeTabs.indexOfFirst { tab -> + // Animated download progress state (smooth sweep) + val animatedDownloadProgress by animateFloatAsState( + targetValue = if (isDownloading || downloadProgress > 0f) downloadProgress else 0f, + animationSpec = if (isDownloadFailed) { + tween(durationMillis = 60, easing = LinearEasing) + } else if (downloadProgress >= 1f) { + tween(durationMillis = 200, easing = LinearEasing) + } else if (downloadProgress == 0f) { + tween(durationMillis = 350, easing = FastOutSlowInEasing) + } else { + tween(durationMillis = 180, easing = LinearEasing) + }, + label = "animatedDownloadProgress" + ) + + // Glow animation for download completion (glow out and glow in 2 times) + val glowAnim = remember { Animatable(0f) } + androidx.compose.runtime.LaunchedEffect(isDownloadCompleted) { + if (isDownloadCompleted) { + // Pulse 1: glow out (0f -> 1f) and glow in (1f -> 0f) + glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing)) + glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing)) + // Pulse 2: glow out (0f -> 1f) and glow in (1f -> 0f) + glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing)) + glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing)) + } else { + glowAnim.snapTo(0f) + } + } + + // Retain last valid active tab index so custom schemes/downloads never jump to Profile + var lastValidTabIndex by remember { mutableIntStateOf(0) } + + val cleanPath = remember(currentUrl) { + val afterScheme = if (currentUrl.contains("://")) currentUrl.substringAfter("://") else currentUrl + val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else "" + pathPart.substringBefore('?').substringBefore('#').trim('/') + } + val isHome = cleanPath.isEmpty() + + val rawTabIndex = activeTabs.indexOfFirst { tab -> when (tab) { - NavTab.HOME -> lowerUrl == "https://www.instagram.com/" || lowerUrl.endsWith("instagram.com/") || lowerUrl == "/" + NavTab.HOME -> isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/" NavTab.SEARCH -> lowerUrl.contains("/explore") NavTab.REELS -> lowerUrl.contains("/reel") NavTab.MESSAGES -> lowerUrl.contains("/direct") || lowerUrl.contains("/inbox") - NavTab.PROFILE -> !lowerUrl.contains("/explore") && !lowerUrl.contains("/reel") && !lowerUrl.contains("/direct") && - (lowerUrl.contains("/profile") || (!lowerUrl.endsWith("instagram.com/") && !lowerUrl.endsWith("instagram.com") && lowerUrl != "/")) + NavTab.PROFILE -> { + val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/") + val isExcluded = isHome || + lowerUrl.contains("/explore") || + lowerUrl.contains("/reel") || + lowerUrl.contains("/direct") || + lowerUrl.contains("/inbox") || + lowerUrl.contains("/p/") || + lowerUrl.contains("/stories/") || + lowerUrl.startsWith("focusgram-download:") || + lowerUrl.startsWith("javascript:") + + val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() } + val isSingleUserSegment = segments.size == 1 + + !isHome && isInstagram && !isExcluded && + (lowerUrl.contains("/profile") || isSingleUserSegment) + } NavTab.SETTINGS -> false } - }.coerceAtLeast(0) + } + + val selectedTabIndex = if (rawTabIndex >= 0) { + lastValidTabIndex = rawTabIndex + rawTabIndex + } else { + lastValidTabIndex.coerceIn(0, activeTabs.lastIndex) + } AnimatedVisibility( visible = isVisible, @@ -219,15 +311,46 @@ fun NativeBottomNavBar( .fillMaxWidth() .height(56.dp) .shadow( - elevation = if (isLight) 8.dp else 4.dp, + elevation = if (glowAnim.value > 0f) (8.dp + 12.dp * glowAnim.value) else (if (isLight) 8.dp else 4.dp), shape = navShape, - spotColor = if (isLight) Color(0x33000000) else Color.Transparent + spotColor = if (glowAnim.value > 0f) Color(0xFF00D2FF).copy(alpha = 0.75f * glowAnim.value) else (if (isLight) Color(0x33000000) else Color.Transparent) ) .clip(navShape) - .background(islandBackground) - .border(BorderStroke(1.dp, borderColor), navShape), + .border(BorderStroke(1.dp, glassBorderBrush), navShape), contentAlignment = Alignment.Center ) { + // 0. Frosted Glass Blur Background Layer (~13% more blur) + Box( + modifier = Modifier + .fillMaxSize() + .clip(navShape) + .blur(20.dp) + .background(islandBackground) + ) + + // 0.1 Specular Glass Sheen Gradient (+4% glass reflection) + Box( + modifier = Modifier + .fillMaxSize() + .clip(navShape) + .background( + Brush.verticalGradient( + colors = if (isLight) { + listOf( + Color(0x24FFFFFF), + Color(0x08FFFFFF), + Color.Transparent + ) + } else { + listOf( + Color(0x1CFFFFFF), + Color(0x06FFFFFF), + Color.Transparent + ) + } + ) + ) + ) val totalWidthPx = constraints.maxWidth.toFloat() val totalHeightPx = constraints.maxHeight.toFloat() val tabCount = activeTabs.size @@ -298,7 +421,7 @@ fun NativeBottomNavBar( ) ) settingsOpened = true - view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + HapticHelper.performLongPress(context, view) longPressProgressAnim.snapTo(0f) onOpenSettings() } catch (_: CancellationException) { @@ -313,7 +436,7 @@ fun NativeBottomNavBar( animJob.cancel() scope.launch { longPressProgressAnim.snapTo(0f) } if (released) { - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + HapticHelper.performClick(context, view) onOpenProfile() onNavClick?.invoke("profile") } @@ -329,7 +452,7 @@ fun NativeBottomNavBar( detectTapGestures( onTap = { val now = SystemClock.uptimeMillis() - view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + HapticHelper.performClick(context, view) when (tab) { NavTab.HOME -> { @@ -453,6 +576,111 @@ fun NativeBottomNavBar( ) } } + + // 4. Media Download Clockwise Perimeter Progress Animation & 2x Completion Glow + val currentDlProgress = animatedDownloadProgress + val currentGlow = glowAnim.value + if (currentDlProgress > 0f || currentGlow > 0f) { + Canvas(modifier = Modifier.fillMaxSize()) { + val fullPath = Path().apply { + moveTo(startXPx, 0f) + lineTo(totalWidthPx - cornerRadiusPx, 0f) + arcTo( + rect = Rect(totalWidthPx - 2 * cornerRadiusPx, 0f, totalWidthPx, 2 * cornerRadiusPx), + startAngleDegrees = 270f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(totalWidthPx, totalHeightPx - cornerRadiusPx) + arcTo( + rect = Rect(totalWidthPx - 2 * cornerRadiusPx, totalHeightPx - 2 * cornerRadiusPx, totalWidthPx, totalHeightPx), + startAngleDegrees = 0f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(cornerRadiusPx, totalHeightPx) + arcTo( + rect = Rect(0f, totalHeightPx - 2 * cornerRadiusPx, 2 * cornerRadiusPx, totalHeightPx), + startAngleDegrees = 90f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(0f, cornerRadiusPx) + arcTo( + rect = Rect(0f, 0f, 2 * cornerRadiusPx, 2 * cornerRadiusPx), + startAngleDegrees = 180f, + sweepAngleDegrees = 90f, + forceMoveTo = false + ) + lineTo(startXPx, 0f) + close() + } + + val blueGradient = Brush.linearGradient( + colors = listOf( + Color(0xFF00E5FF), // Bright Cyan + Color(0xFF0091FF), // Electric Sky Blue + Color(0xFF0055FF), // Deep Vivid Blue + Color(0xFF00D2FF) // Cyan-Blue + ) + ) + val failureGradient = Brush.linearGradient( + colors = listOf( + Color(0xFFFF3B30), // Bright Red + Color(0xFFCC0000), // Dark Red + Color(0xFF8B0000), // Very Dark Crimson + Color(0xFFFF3B30) // Bright Red + ) + ) + + // 4a. If downloading or failed: Clockwise perimeter sweep starting above Home icon + if (currentDlProgress > 0f && currentGlow == 0f) { + val pathMeasure = PathMeasure() + pathMeasure.setPath(fullPath, false) + val totalLen = pathMeasure.length + val segmentPath = Path() + pathMeasure.getSegment(0f, totalLen * currentDlProgress.coerceIn(0f, 1f), segmentPath, true) + + drawPath( + path = segmentPath, + brush = if (isDownloadFailed) failureGradient else blueGradient, + style = Stroke( + width = 3.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + + // 4b. If download completed: 2x glowing outer halo + luminous neon perimeter rim + if (currentGlow > 0f) { + // Outer diffused glow halo + drawPath( + path = fullPath, + color = Color(0xFF00D2FF).copy(alpha = 0.6f * currentGlow), + style = Stroke( + width = (4.dp + 5.dp * currentGlow).toPx(), + cap = StrokeCap.Round + ) + ) + // Core luminous neon rim + drawPath( + path = fullPath, + brush = Brush.linearGradient( + colors = listOf( + Color(0xFF80EEFF).copy(alpha = currentGlow), + Color(0xFF00B4FF).copy(alpha = currentGlow), + Color(0xFF0066FF).copy(alpha = currentGlow), + Color(0xFF80EEFF).copy(alpha = currentGlow) + ) + ), + style = Stroke( + width = 3.2.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + } + } } } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt index 07c80cc..ed294da 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeService.kt @@ -11,6 +11,8 @@ import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import android.content.pm.ServiceInfo import com.ujwal.focusgram.FocusGramApp import com.ujwal.focusgram.MainActivity import com.ujwal.focusgram.R @@ -45,6 +47,27 @@ class ScreenTimeService : Service() { override fun onCreate() { super.onCreate() createNotificationChannel() + val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...") + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + initialNotification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE + ) + } else { + startForeground(NOTIFICATION_ID, initialNotification) + } + } catch (e: Exception) { + Log.e(TAG, "Primary startForeground failed in onCreate: ${e.message}", e) + try { + startForeground(NOTIFICATION_ID, initialNotification) + } catch (fallbackEx: Exception) { + Log.e(TAG, "Fallback startForeground failed: ${fallbackEx.message}", fallbackEx) + stopSelf() + } + } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -68,6 +91,14 @@ class ScreenTimeService : Service() { } ACTION_STOP -> { stopTracking() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + } catch (_: Exception) {} stopSelf() } } @@ -80,8 +111,7 @@ class ScreenTimeService : Service() { sessionStartTime = System.currentTimeMillis() sessionSeconds = 0 - val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...") - startForeground(NOTIFICATION_ID, initialNotification) + updateNotification("Focus Session Active", "Tracking active session time...") trackingJob = serviceScope.launch { val app = FocusGramApp.instance @@ -192,7 +222,7 @@ class ScreenTimeService : Service() { return NotificationCompat.Builder(this, CHANNEL_ID) .setContentTitle(title) .setContentText(content) - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.drawable.ic_notification) .setContentIntent(pendingIntent) .setOngoing(true) .setSilent(true) @@ -201,8 +231,12 @@ class ScreenTimeService : Service() { } private fun updateNotification(title: String, content: String) { - val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - manager.notify(NOTIFICATION_ID, buildNotification(title, content)) + try { + val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(NOTIFICATION_ID, buildNotification(title, content)) + } catch (e: Exception) { + Log.w(TAG, "Failed to update notification: ${e.message}") + } } override fun onDestroy() { @@ -224,14 +258,18 @@ class ScreenTimeService : Service() { const val EXTRA_IS_REELS = "extra_is_reels" fun startSession(context: Context, isReels: Boolean = false) { - val intent = Intent(context, ScreenTimeService::class.java).apply { - action = ACTION_START - putExtra(EXTRA_IS_REELS, isReels) - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - context.startForegroundService(intent) - } else { - context.startService(intent) + try { + val intent = Intent(context, ScreenTimeService::class.java).apply { + action = ACTION_START + putExtra(EXTRA_IS_REELS, isReels) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + } catch (e: Exception) { + Log.e(TAG, "Failed to startSession: ${e.message}", e) } } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt index 0f8462b..35d03df 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt @@ -28,16 +28,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.utils.HapticHelper /** * SessionExhaustedScreen @@ -54,6 +57,13 @@ fun SessionExhaustedScreen( onWatchAdToExtend: () -> Unit, modifier: Modifier = Modifier ) { + val context = LocalContext.current + + // Trigger physical session completion vibration alert (double buzz) + LaunchedEffect(Unit) { + HapticHelper.performSessionCompletionAlert(context) + } + // Lock down back navigation: back button will exit the app, never return to webview BackHandler(enabled = true) { onExit() diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt index c1e1470..55d1f30 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/settings/SettingsScreen.kt @@ -2,6 +2,11 @@ package com.ujwal.focusgram.feature.settings import android.content.Intent import android.net.Uri +import android.os.Build +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import com.ujwal.focusgram.R import androidx.activity.compose.BackHandler import androidx.compose.foundation.BorderStroke @@ -49,6 +54,7 @@ import androidx.compose.material.icons.filled.Security import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.ShoppingBag import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StayCurrentPortrait import androidx.compose.material.icons.filled.Timer import androidx.compose.material.icons.filled.Visibility import androidx.compose.material.icons.filled.VisibilityOff @@ -126,9 +132,12 @@ fun SettingsScreen( onNavigateScreenTime: () -> Unit, onNavigateAppLock: () -> Unit, onWatchAd: () -> Unit = {}, + onWatchAdWithReward: (((suspend () -> Unit) -> Unit))? = null, onNavigateUrl: (String) -> Unit = {}, screenTimeDao: ScreenTimeDao? = null, reelHistory: ReelHistory? = null, + currentSubpage: SettingsSubpage = SettingsSubpage.MAIN, + onSubpageChange: (SettingsSubpage) -> Unit = {}, modifier: Modifier = Modifier ) { val settings by repository.settingsFlow.collectAsState(initial = FocusSettings()) @@ -136,14 +145,28 @@ fun SettingsScreen( val context = LocalContext.current val mainListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } - var currentSubpage by remember { mutableStateOf(SettingsSubpage.MAIN) } + var activeSubpage by remember(currentSubpage) { mutableStateOf(currentSubpage) } + val setSubpage: (SettingsSubpage) -> Unit = { page -> + activeSubpage = page + onSubpageChange(page) + } var showLegalDialog by remember { mutableStateOf(false) } var showDeveloperDialog by remember { mutableStateOf(false) } + var pendingRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) } + var adDialogReason by remember { mutableStateOf(null) } + var showSupportAdDialog by remember { mutableStateOf(false) } + + val onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { action, reason -> + pendingRewardAction = action + adDialogReason = reason + showSupportAdDialog = true + } + // Intercept back presses: go back to MAIN subpage first, or pop screen BackHandler { - if (currentSubpage != SettingsSubpage.MAIN) { - currentSubpage = SettingsSubpage.MAIN + if (activeSubpage != SettingsSubpage.MAIN) { + setSubpage(SettingsSubpage.MAIN) } else { onBack() } @@ -271,6 +294,78 @@ fun SettingsScreen( ) } + // Support Ad Dialog + if (showSupportAdDialog && adDialogReason != null) { + AlertDialog( + onDismissRequest = { + showSupportAdDialog = false + pendingRewardAction = null + }, + containerColor = Color(0xFF1C1C1E), + title = { + Text( + text = "Support FocusGram ❤️", + color = Color.White, + fontWeight = FontWeight.Bold, + fontSize = 18.sp + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = adDialogReason ?: "Please watch an ad in order to toggle this setting.", + color = Color.White, + fontWeight = FontWeight.Medium, + fontSize = 14.sp + ) + Text( + text = "Your 20 seconds can provide a huge contribution to the continuous development and improvement of FocusGram.\n\nThank you for supporting this project! ❤️", + color = Color(0xFF8E8E93), + fontSize = 13.sp, + lineHeight = 18.sp + ) + } + }, + confirmButton = { + Button( + onClick = { + showSupportAdDialog = false + val action = pendingRewardAction + pendingRewardAction = null + if (action != null) { + if (onWatchAdWithReward != null) { + onWatchAdWithReward(action) + } else { + scope.launch { action() } + onWatchAd() + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.White, + contentColor = Color.Black + ), + shape = RoundedCornerShape(20.dp) + ) { + Text("Watch Ad & Continue", fontWeight = FontWeight.Bold) + } + }, + dismissButton = { + OutlinedButton( + onClick = { + showSupportAdDialog = false + pendingRewardAction = null + }, + border = BorderStroke(1.dp, Color(0xFF3A3A3C)), + shape = RoundedCornerShape(20.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White) + ) { + Text("Cancel") + } + } + ) + } + Column( modifier = modifier .fillMaxSize() @@ -285,8 +380,8 @@ fun SettingsScreen( verticalAlignment = Alignment.CenterVertically ) { IconButton(onClick = { - if (currentSubpage != SettingsSubpage.MAIN) { - currentSubpage = SettingsSubpage.MAIN + if (activeSubpage != SettingsSubpage.MAIN) { + setSubpage(SettingsSubpage.MAIN) } else { onBack() } @@ -298,7 +393,7 @@ fun SettingsScreen( ) } Text( - text = when (currentSubpage) { + text = when (activeSubpage) { SettingsSubpage.MAIN -> "Settings and activity" SettingsSubpage.FOCUS -> "Focus Mode" SettingsSubpage.GUARDRAILS -> "Time Control & Guardrails" @@ -316,12 +411,12 @@ fun SettingsScreen( } // Subpage Router - when (currentSubpage) { + when (activeSubpage) { SettingsSubpage.MAIN -> { MainSettingsContent( settings = settings, listState = mainListState, - onNavigateSubpage = { currentSubpage = it }, + onNavigateSubpage = { setSubpage(it) }, onNavigateAppLock = onNavigateAppLock, onNavigateScreenTime = onNavigateScreenTime, onNavigateReelsHistory = onNavigateReelsHistory, @@ -333,7 +428,11 @@ fun SettingsScreen( ) } SettingsSubpage.FOCUS -> { - FocusModeSubpage(repository = repository, settings = settings) + FocusModeSubpage( + repository = repository, + settings = settings, + onRequestAdReward = onRequestAdReward + ) } SettingsSubpage.GUARDRAILS -> { GuardrailsSubpage( @@ -347,6 +446,7 @@ fun SettingsScreen( ExtrasSubpage( repository = repository, settings = settings, + onRequestAdReward = onRequestAdReward, onNavigateUrl = { url -> onNavigateUrl(url) onBack() @@ -585,7 +685,7 @@ private fun MainSettingsContent( IOSGroupedCard { IOSSettingsRow( title = "Extras", - subtitle = "Startup launch page, download media", + subtitle = "Startup launch page, media downloader", icon = Icons.Default.Star, iconBgColor = IOSBlue, onClick = { onNavigateSubpage(SettingsSubpage.EXTRAS) }, @@ -713,7 +813,8 @@ private fun MainSettingsContent( @Composable private fun FocusModeSubpage( repository: SettingsRepository, - settings: FocusSettings + settings: FocusSettings, + onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> } ) { val scope = rememberCoroutineScope() @@ -887,7 +988,16 @@ private fun FocusModeSubpage( icon = Icons.Default.Block, iconBgColor = IOSGreen, checked = settings.noAds, - onCheckedChange = { scope.launch { repository.setNoAds(it) } }, + onCheckedChange = { willEnable -> + if (willEnable) { + onRequestAdReward( + { repository.setNoAds(true) }, + "Please watch an ad in order to re-enable Ad Blocking." + ) + } else { + scope.launch { repository.setNoAds(false) } + } + }, showDivider = false ) } @@ -1347,9 +1457,11 @@ private fun GhostModeSubpage( private fun ExtrasSubpage( repository: SettingsRepository, settings: FocusSettings, + onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> }, onNavigateUrl: (String) -> Unit = {} ) { val scope = rememberCoroutineScope() + var showLaunchPageModal by remember { mutableStateOf(false) } LazyColumn( modifier = Modifier @@ -1360,56 +1472,38 @@ private fun ExtrasSubpage( item { IOSSectionTitle(title = "STARTUP") IOSGroupedCard { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - ) { - Text( - text = "Launch Page", - color = Color.White, - fontSize = 16.sp, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "Choose which page opens when you launch FocusGram.", - color = Color(0xFF8E8E93), - fontSize = 13.sp - ) - - Spacer(modifier = Modifier.height(14.dp)) - - val pages = listOf( - "home" to "Home Feed", - "following" to "Following Feed", - "favorites" to "Favorites Feed", - "direct" to "Direct Messages" - ) - - pages.forEach { (key, label) -> - val isSelected = settings.startupPage == key - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(if (isSelected) Color.White.copy(alpha = 0.1f) else Color.Transparent) - .clickable { scope.launch { repository.setStartupPage(key) } } - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = label, - color = if (isSelected) Color.White else Color(0xFF8E8E93), - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - fontSize = 14.sp - ) - if (isSelected) { - Text("✓", color = Color.White, fontWeight = FontWeight.Bold) + val isCustomLaunch = settings.startupPage != "home" + IOSSettingsRow( + title = "Launch Page", + subtitle = if (isCustomLaunch) "Start on ${when (settings.startupPage) { "following" -> "Following Feed"; "favorites" -> "Favorites Feed"; "direct" -> "Direct Messages"; else -> "Home Feed" }}" else "Opens default Instagram Home", + icon = Icons.AutoMirrored.Filled.OpenInNew, + iconBgColor = IOSBlue, + checked = isCustomLaunch, + onCheckedChange = { isChecked -> + scope.launch { + if (isChecked) { + repository.setStartupPage("following") + showLaunchPageModal = true + } else { + repository.setStartupPage("home") } } - } + }, + showDivider = isCustomLaunch + ) + if (isCustomLaunch) { + IOSSettingsRow( + title = "Selected Target", + subtitle = "Choose screen to open upon launch", + trailingText = when (settings.startupPage) { + "following" -> "Following Feed" + "favorites" -> "Favorites Feed" + "direct" -> "Direct Messages" + else -> "Home Feed" + }, + onClick = { showLaunchPageModal = true }, + showDivider = false + ) } } } @@ -1419,12 +1513,38 @@ private fun ExtrasSubpage( IOSSectionTitle(title = "MEDIA") IOSGroupedCard { IOSSettingsRow( - title = "Download Media (Feed + Reels)", - subtitle = "Adds a download option on posts and reels", + title = "Media Downloader", + subtitle = "Download media from Feed, Reels, and Profiles", icon = Icons.Default.Star, iconBgColor = IOSOrange, checked = settings.videoDownloadEnabled, - onCheckedChange = { scope.launch { repository.setVideoDownloadEnabled(it) } }, + onCheckedChange = { newState -> + val actionDesc = if (newState) "enable" else "disable" + onRequestAdReward( + { repository.setVideoDownloadEnabled(newState) }, + "Please watch an ad in order to $actionDesc the Media Downloader." + ) + }, + showDivider = false + ) + } + } + + // DISPLAY + item { + IOSSectionTitle(title = "DISPLAY") + IOSGroupedCard { + IOSSettingsRow( + title = "Portrait Only", + subtitle = "Lock app orientation to portrait mode", + icon = Icons.Default.StayCurrentPortrait, + iconBgColor = IOSBlue, + checked = settings.portraitOnly, + onCheckedChange = { isChecked -> + scope.launch { + repository.setPortraitOnly(isChecked) + } + }, showDivider = false ) } @@ -1451,6 +1571,31 @@ private fun ExtrasSubpage( Spacer(modifier = Modifier.height(40.dp)) } } + + if (showLaunchPageModal) { + val launchPages = remember { + listOf( + "home" to "Home Feed", + "following" to "Following Feed", + "favorites" to "Favorites Feed", + "direct" to "Direct Messages" + ) + } + val currentIdx = remember(settings.startupPage) { + launchPages.indexOfFirst { it.first == settings.startupPage }.coerceAtLeast(0) + } + WheelPickerModalDialog( + title = "Launch Page", + subtitle = "Choose which page opens when you launch FocusGram", + options = launchPages, + initialIndex = currentIdx, + onDismiss = { showLaunchPageModal = false }, + onConfirm = { option -> + scope.launch { repository.setStartupPage(option.first) } + }, + labelProvider = { it.second } + ) + } } // ─── APPEARANCE SUBPAGE ───────────────────────────────────────────────────── @@ -1496,6 +1641,26 @@ private fun NotificationsSubpage( settings: FocusSettings ) { val scope = rememberCoroutineScope() + val context = LocalContext.current + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { _ -> } + + val checkAndRequestNotificationPermission = { onGranted: () -> Unit -> + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } else { + onGranted() + } + } else { + onGranted() + } + } LazyColumn( modifier = Modifier @@ -1528,7 +1693,16 @@ private fun NotificationsSubpage( icon = Icons.Default.Notifications, iconBgColor = IOSTeal, checked = settings.notifySessionEnd, - onCheckedChange = { scope.launch { repository.setNotifySessionEnd(it) } } + onCheckedChange = { willEnable -> + if (willEnable) { + checkAndRequestNotificationPermission { + scope.launch { repository.setNotifySessionEnd(true) } + } + scope.launch { repository.setNotifySessionEnd(true) } + } else { + scope.launch { repository.setNotifySessionEnd(false) } + } + } ) IOSSettingsRow( title = "Persistent Status Notification", @@ -1536,7 +1710,16 @@ private fun NotificationsSubpage( icon = Icons.Default.Timer, iconBgColor = IOSBlue, checked = settings.notifyPersistent, - onCheckedChange = { scope.launch { repository.setNotifyPersistent(it) } }, + onCheckedChange = { willEnable -> + if (willEnable) { + checkAndRequestNotificationPermission { + scope.launch { repository.setNotifyPersistent(true) } + } + scope.launch { repository.setNotifyPersistent(true) } + } else { + scope.launch { repository.setNotifyPersistent(false) } + } + }, showDivider = false ) } diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt index 06fde8c..d4afa44 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt @@ -157,9 +157,10 @@ fun AdsterraAdScreen( var adsClicked by remember { mutableIntStateOf(0) } var webViewRef by remember { mutableStateOf(null) } + val isSettingToggle = sessionType == "setting_toggle" val timerDone = elapsedSeconds >= requiredSeconds val bothClicked = adsClicked >= 2 - val isComplete = timerDone && bothClicked + val isComplete = if (isSettingToggle) timerDone else (timerDone && bothClicked) // 20s Timer Loop - restarts cleanly on timerCycle increment LaunchedEffect(timerCycle) { @@ -171,7 +172,8 @@ fun AdsterraAdScreen( } val statusText = when { - isComplete -> "Reward Unlocked! Tap below to claim focus minutes." + isComplete -> if (isSettingToggle) "Ad viewed! Tap below to apply your setting." else "Reward Unlocked! Tap below to claim focus minutes." + isSettingToggle -> "Viewing ad (${requiredSeconds - elapsedSeconds}s remaining)..." bothClicked -> "Both ads clicked! Waiting for countdown (${requiredSeconds - elapsedSeconds}s)..." timerDone -> "Timer completed. Please tap both ads ($adsClicked/2) to continue." else -> "Tap BOTH ads below to earn reward credits ($adsClicked/2 clicked)" @@ -400,8 +402,13 @@ fun AdsterraAdScreen( modifier = Modifier.size(18.dp) ) Spacer(modifier = Modifier.width(6.dp)) + val buttonLabel = when { + !isComplete -> if (isSettingToggle) "Viewing Ad (${(requiredSeconds - elapsedSeconds).coerceAtLeast(0)}s)" else "Complete Ad Actions to Unlock" + isSettingToggle -> "Continue & Apply Setting" + else -> "Continue & Earn Reward (+2 min)" + } Text( - text = if (isComplete) "Continue & Earn Reward (+2 min)" else "Complete Ad Actions to Unlock", + text = buttonLabel, fontWeight = FontWeight.Bold, fontSize = 15.sp ) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt index e08b5a0..9640831 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/BreathGateScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.scale import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.ujwal.focusgram.core.ui.theme.InstagramBlue import com.ujwal.focusgram.core.ui.theme.PureBlack +import com.ujwal.focusgram.core.utils.HapticHelper import kotlinx.coroutines.delay /** @@ -54,6 +56,7 @@ fun BreathGateScreen( durationSeconds: Int = 10, onFinish: () -> Unit ) { + val context = LocalContext.current val initialSeconds = durationSeconds.coerceIn(3, 60) var secondsRemaining by remember { mutableIntStateOf(initialSeconds) } val canContinue = secondsRemaining <= 0 @@ -64,6 +67,7 @@ fun BreathGateScreen( delay(1000L) secondsRemaining-- } + HapticHelper.performSuccessPulse(context) } // Mindful Breathing Expansion/Contraction Animation diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt index 33645ab..0352453 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/wellness/CooldownGateScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -43,6 +44,7 @@ import com.ujwal.focusgram.core.ui.theme.DarkCard import com.ujwal.focusgram.core.ui.theme.PureBlack import com.ujwal.focusgram.core.ui.theme.TextMuted import com.ujwal.focusgram.core.ui.theme.TextSecondary +import com.ujwal.focusgram.core.utils.HapticHelper import kotlinx.coroutines.delay /** @@ -55,6 +57,7 @@ fun CooldownGateScreen( onCooldownExpired: () -> Unit, onExit: () -> Unit ) { + val context = LocalContext.current var remainingSeconds by remember { mutableIntStateOf(remainingSecondsInitial.coerceAtLeast(0)) } val quotes = remember { @@ -71,6 +74,7 @@ fun CooldownGateScreen( LaunchedEffect(remainingSeconds) { if (remainingSeconds <= 0) { + HapticHelper.performSuccessPulse(context) onCooldownExpired() } else { delay(1000L) diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt index ea9e558..507c9da 100644 --- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt +++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt @@ -5,6 +5,7 @@ import android.net.Uri import android.util.Log import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -18,13 +19,21 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.content.pm.ActivityInfo import android.os.Build +import android.provider.MediaStore import androidx.core.app.NotificationCompat +import androidx.core.content.FileProvider +import java.io.File +import java.util.concurrent.atomic.AtomicReference import com.ujwal.focusgram.MainActivity import com.ujwal.focusgram.R +import com.ujwal.focusgram.core.utils.HapticHelper import com.ujwal.focusgram.core.utils.MediaDownloadHelper import com.ujwal.focusgram.feature.screentime.ScreenTimeService +import androidx.compose.ui.draw.blur import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -52,6 +61,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -122,7 +132,14 @@ enum class AppDestination { @Composable fun FocusGramApp() { val context = LocalContext.current - val activity = context as? Activity + val activity = remember(context) { + var ctx = context + while (ctx is ContextWrapper) { + if (ctx is Activity) break + ctx = ctx.baseContext + } + ctx as? Activity + } val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() @@ -177,6 +194,8 @@ fun FocusGramApp() { var isWebInputFocused by remember { mutableStateOf(false) } var isModalActive by remember { mutableStateOf(false) } + var isUserAuthenticated by remember { mutableStateOf(false) } + var isAuthPage by remember { mutableStateOf(false) } // Session intention countdown timer state var sessionDurationMinutes by remember { mutableIntStateOf(15) } @@ -184,6 +203,8 @@ fun FocusGramApp() { var totalElapsedSeconds by remember { mutableIntStateOf(0) } var adExtensionsUsed by remember { mutableIntStateOf(0) } var isSessionExhausted by remember { mutableStateOf(false) } + var adRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) } + var settingsSubpage by rememberSaveable { mutableStateOf(com.ujwal.focusgram.feature.settings.SettingsSubpage.MAIN) } // Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active val isGateActive = shouldShowLock || @@ -193,59 +214,118 @@ fun FocusGramApp() { (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) || !hasPassedSessionPicker - // System File Chooser / Gallery Launchers for Post & Story uploads - var pendingFilePickerCallback by remember { mutableStateOf<((List) -> Unit)?>(null) } + // System File Chooser / Camera / Gallery Launchers for Story, Post, DMs & Profile Uploads + val pendingFilePickerCallback = remember { AtomicReference<((List) -> Unit)?>(null) } + val pendingCameraCaptureUri = remember { AtomicReference(null) } - val filePickerLauncher = rememberLauncherForActivityResult( + // 1. Android Modern Photo & Video Pickers (Official AndroidX contracts, zero permissions needed) + val singlePhotoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + val callback = pendingFilePickerCallback.getAndSet(null) + Log.i("FocusGramApp", "Single PhotoPicker result: $uri") + callback?.invoke(if (uri != null) listOf(uri) else emptyList()) + } + + val multiPhotoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickMultipleVisualMedia() + ) { uris -> + val callback = pendingFilePickerCallback.getAndSet(null) + Log.i("FocusGramApp", "Multi PhotoPicker result: ${uris.size} URIs: ${uris.joinToString()}") + callback?.invoke(uris) + } + + // 2. Direct Camera Capture Launcher (Used when HTML input requests camera capture) + val takePictureLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.TakePicture() + ) { success -> + val callback = pendingFilePickerCallback.getAndSet(null) + val uri = pendingCameraCaptureUri.getAndSet(null) + Log.i("FocusGramApp", "TakePicture result: success=$success, uri=$uri") + if (success && uri != null) { + callback?.invoke(listOf(uri)) + } else { + callback?.invoke(emptyList()) + } + } + + // 3. System Document/Gallery Fallback Launcher + val fallbackFilePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.StartActivityForResult() ) { result -> val uris = mutableListOf() - val data = result.data - if (result.resultCode == android.app.Activity.RESULT_OK && data != null) { - val clipData = data.clipData - if (clipData != null) { + val callback = pendingFilePickerCallback.getAndSet(null) + if (result.resultCode == Activity.RESULT_OK) { + val data = result.data + val clipData = data?.clipData + val dataUri = data?.data + if (clipData != null && clipData.itemCount > 0) { for (i in 0 until clipData.itemCount) { clipData.getItemAt(i).uri?.let { uris.add(it) } } - } else { - data.data?.let { uris.add(it) } + } else if (dataUri != null) { + uris.add(dataUri) } } - pendingFilePickerCallback?.invoke(uris) - pendingFilePickerCallback = null + Log.i("FocusGramApp", "Fallback file picker result code: ${result.resultCode}, selected: ${uris.size} URIs") + callback?.invoke(uris) } // Android Runtime Permissions Launcher (Camera, Audio, Storage) - var pendingPermissionCallback by remember { mutableStateOf<((Boolean) -> Unit)?>(null) } + val pendingPermissionCallback = remember { AtomicReference<((Boolean) -> Unit)?>(null) } val permissionLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.RequestMultiplePermissions() ) { results -> val allGranted = results.values.all { it } - pendingPermissionCallback?.invoke(allGranted) - pendingPermissionCallback = null + val callback = pendingPermissionCallback.getAndSet(null) + callback?.invoke(allGranted) } - val filePickerHandler = remember { - object : FocusGramPromptDelegate.FilePickerHandler { - var launchChooser: ((Array, Boolean) -> Unit)? = null + val notificationPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission() + ) { isGranted -> + Log.i("FocusGramApp", "Notification permission result: $isGranted") + } - override fun onPickFiles( - mimeTypes: Array, - allowMultiple: Boolean, - onResult: (List) -> Unit - ) { - pendingFilePickerCallback = onResult - try { - launchChooser?.invoke(mimeTypes, allowMultiple) ?: onResult(emptyList()) - } catch (e: Exception) { - Log.e("FocusGramApp", "Error launching file picker: ${e.message}", e) - onResult(emptyList()) + LaunchedEffect(settings.notifyPersistent, settings.notifySessionEnd) { + if (settings.notifyPersistent || settings.notifySessionEnd) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (androidx.core.content.ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.POST_NOTIFICATIONS + ) != android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS) } } } } - filePickerHandler.launchChooser = { mimes, allowMultiple -> + val filePickerHandler = remember { + object : FocusGramPromptDelegate.FilePickerHandler { + var launchChooser: ((Array, Boolean, Int) -> Unit)? = null + + override fun onPickFiles( + mimeTypes: Array, + allowMultiple: Boolean, + capture: Int, + onResult: (List) -> Unit + ) { + pendingFilePickerCallback.set(onResult) + try { + launchChooser?.invoke(mimeTypes, allowMultiple, capture) ?: run { + Log.w("FocusGramApp", "launchChooser was null, returning empty list") + pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList()) + } + } catch (e: Exception) { + Log.e("FocusGramApp", "Error invoking launchChooser: ${e.message}", e) + pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList()) + } + } + } + } + + filePickerHandler.launchChooser = { mimes, allowMultiple, capture -> val mimeMap = android.webkit.MimeTypeMap.getSingleton() val cleanMimes = mimes .flatMap { it.split(",") } @@ -263,35 +343,120 @@ fun FocusGramApp() { .distinct() .toTypedArray() - val hasImages = cleanMimes.any { it.startsWith("image/") } || cleanMimes.isEmpty() - val hasVideos = cleanMimes.any { it.startsWith("video/") } + Log.i("FocusGramApp", "Launching media picker for cleanMimes: ${cleanMimes.joinToString()}, allowMultiple: $allowMultiple, capture: $capture") - val intent = android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply { - addCategory(android.content.Intent.CATEGORY_OPENABLE) - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) - if (allowMultiple) { - putExtra(android.content.Intent.EXTRA_ALLOW_MULTIPLE, true) - } - if (hasImages && !hasVideos) { - type = "image/*" - if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) { - putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes) - } - } else if (hasVideos && !hasImages) { - type = "video/*" - if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) { - putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes) - } - } else { - type = "*/*" - val fallbackTypes = if (cleanMimes.isNotEmpty()) cleanMimes else arrayOf("image/*", "video/*") - putExtra(android.content.Intent.EXTRA_MIME_TYPES, fallbackTypes) + val isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*") + val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") } + val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") } + + val currentUrlLower = sessionManager.currentUrl.value.lowercase() + val isProfilePictureEdit = currentUrlLower.contains("/accounts/edit") + + // In Instagram, DMs (/direct/), Stories, and Feed Posts all support videos (MP4, MOV, WebM). + // Unless explicitly updating an account avatar/profile picture, always allow both Photos and Videos. + val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages) + val allowsImages = hasImages || isAnyMedia + + var launched = false + + // 1. Direct Camera Capture Mode (Explicitly requested by web page via capture attribute) + if (capture != org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.NONE) { + try { + val cameraDir = File(context.cacheDir, "camera").apply { mkdirs() } + val photoFile = File(cameraDir, "camera_capture.jpg") + if (photoFile.exists()) photoFile.delete() + val photoUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + photoFile + ) + pendingCameraCaptureUri.set(photoUri) + takePictureLauncher.launch(photoUri) + launched = true + } catch (eCamera: Exception) { + Log.w("FocusGramApp", "Direct camera launch failed: ${eCamera.message}, trying photo picker", eCamera) } } - val chooser = android.content.Intent.createChooser(intent, "Select Media").apply { - addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION) + + // 2. Primary Media Picker: Modern Android Photo & Video Picker (PickVisualMedia) + // Works seamlessly on Android 11+ and Samsung Galaxy OneUI without any permissions + if (!launched) { + try { + val visualMediaType = when { + allowsImages && !allowsVideos -> ActivityResultContracts.PickVisualMedia.ImageOnly + allowsVideos && !allowsImages -> ActivityResultContracts.PickVisualMedia.VideoOnly + else -> ActivityResultContracts.PickVisualMedia.ImageAndVideo + } + val request = PickVisualMediaRequest.Builder() + .setMediaType(visualMediaType) + .build() + + if (allowMultiple) { + multiPhotoPickerLauncher.launch(request) + } else { + singlePhotoPickerLauncher.launch(request) + } + launched = true + } catch (ePhotoPicker: Exception) { + Log.w("FocusGramApp", "PickVisualMedia failed: ${ePhotoPicker.message}, attempting ACTION_GET_CONTENT", ePhotoPicker) + } + } + + // 3. Fallback 1: ACTION_GET_CONTENT (with CATEGORY_OPENABLE for Samsung & AOSP DocumentsUI) + if (!launched) { + try { + val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + if (allowMultiple) { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + } + if (allowsImages && !allowsVideos) { + type = "image/*" + if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) { + putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes) + } + } else if (allowsVideos && !allowsImages) { + type = "video/*" + if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) { + putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes) + } + } else { + type = "*/*" + val fallbackTypes = if (cleanMimes.isNotEmpty() && allowsVideos && !hasVideos) { + (cleanMimes.toList() + listOf("video/*", "video/mp4")).toTypedArray() + } else if (cleanMimes.isNotEmpty()) { + cleanMimes + } else { + arrayOf("image/*", "video/*") + } + putExtra(Intent.EXTRA_MIME_TYPES, fallbackTypes) + } + } + fallbackFilePickerLauncher.launch(getContentIntent) + launched = true + } catch (eGetContent: Exception) { + Log.w("FocusGramApp", "ACTION_GET_CONTENT failed: ${eGetContent.message}, attempting ACTION_PICK", eGetContent) + } + } + + // 4. Fallback 2: ACTION_PICK from MediaStore + if (!launched) { + try { + val pickIntent = if (allowsVideos && !allowsImages) { + Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI) + } else { + Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI) + }.apply { + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + fallbackFilePickerLauncher.launch(pickIntent) + launched = true + } catch (ePick: Exception) { + Log.e("FocusGramApp", "All file picker fallbacks failed: ${ePick.message}", ePick) + pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList()) + } } - filePickerLauncher.launch(chooser) } // Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView) @@ -343,6 +508,7 @@ fun FocusGramApp() { if (totalTodaySeconds >= settings.dailyLimitMinutes * 60) { isSessionExhausted = true settingsRepository.recordSessionEnd() + HapticHelper.performSessionCompletionAlert(context) break } } @@ -351,6 +517,7 @@ fun FocusGramApp() { if (sessionRemainingSeconds <= 0) { isSessionExhausted = true settingsRepository.recordSessionEnd() + HapticHelper.performSessionCompletionAlert(context) if (settings.notifySessionEnd) { postSessionExpiredNotification(context) } @@ -480,9 +647,20 @@ fun FocusGramApp() { } } } + extensionManager.onDownloadStarted = { filename -> + MediaDownloadHelper.startIndeterminateProgress(filename) + } extensionManager.onDownloadRequested = { url, filename -> MediaDownloadHelper.download(context, url, filename) } + extensionManager.onDownloadError = { error -> + MediaDownloadHelper.triggerDownloadFailure(context, error) + } + extensionManager.onAuthStateChanged = { authenticated, authPage -> + isUserAuthenticated = authenticated + isAuthPage = authPage + sessionManager.setAuthenticated(authenticated) + } } LaunchedEffect(reelHistory, database) { @@ -501,6 +679,10 @@ fun FocusGramApp() { sessionManager = sessionManager, onRouteChanged = { newUrl -> val mode = IslandMode.fromUrl(newUrl) + val lower = newUrl.lowercase() + if (lower.contains("/accounts") || lower.contains("/login") || lower.contains("/signup")) { + isAuthPage = true + } sessionManager.updateCurrentUrl(newUrl) sessionManager.updateIslandMode(mode) extensionManager.setIslandMode(mode) @@ -517,7 +699,7 @@ fun FocusGramApp() { sessionManager.session.permissionDelegate = FocusGramPermissionDelegate( context = context, onAndroidPermissionRequest = { perms, callback -> - pendingPermissionCallback = callback + pendingPermissionCallback.set(callback) permissionLauncher.launch(perms) } ) @@ -562,6 +744,15 @@ fun FocusGramApp() { previousReloadSettings = settings } + // Dynamic screen orientation lock: enforce portrait-only when enabled + LaunchedEffect(settings.portraitOnly, activity) { + activity?.requestedOrientation = if (settings.portraitOnly) { + ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } else { + ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED + } + } + // When any barrier, gate, lock, or intention picker is active, pressing back exits the app cleanly BackHandler(enabled = isGateActive && destination != AppDestination.ADSTERRA_REWARDED) { activity?.finish() @@ -672,10 +863,11 @@ fun FocusGramApp() { sessionManager.refreshReels() }, onDownloadClick = { + MediaDownloadHelper.startIndeterminateProgress("Preparing Reel download...") extensionManager.triggerReelDownload() sessionManager.userScriptManager?.evaluate( sessionManager.session, - "window.dispatchEvent(new CustomEvent('focusgram:download-reel'));" + "if (typeof window.__fgDownloadActiveReel === 'function') { window.__fgDownloadActiveReel(); } else { window.dispatchEvent(new CustomEvent('focusgram:download-reel', { bubbles: true })); }" ) }, modifier = Modifier.align(Alignment.TopCenter) @@ -688,6 +880,7 @@ fun FocusGramApp() { .align(Alignment.BottomCenter) .fillMaxWidth() .height(navBarTotalHeight) + .blur(18.dp) .background( Brush.verticalGradient( colors = if (isAppLight) { @@ -712,6 +905,7 @@ fun FocusGramApp() { // Native Bottom Navigation Bar (OLED floating island) if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) { val instagramTheme by sessionManager.instagramTheme.collectAsState() + val downloadState by MediaDownloadHelper.downloadProgressFlow.collectAsState() NativeBottomNavBar( currentUrl = currentUrl, settings = settings, @@ -719,6 +913,12 @@ fun FocusGramApp() { instagramTheme = instagramTheme, isWebInputFocused = isWebInputFocused, isModalActive = isModalActive, + downloadProgress = downloadState.progress, + isDownloading = downloadState.isDownloading, + isDownloadCompleted = downloadState.isCompleted, + isDownloadFailed = downloadState.isFailed, + isAuthenticated = isUserAuthenticated, + isAuthPage = isAuthPage, onNavClick = { target -> extensionManager.go(target) }, @@ -776,12 +976,18 @@ fun FocusGramApp() { onNavigateScreenTime = { destination = AppDestination.SCREEN_TIME }, onNavigateAppLock = { destination = AppDestination.APP_LOCK_SETTINGS }, onWatchAd = { destination = AppDestination.ADSTERRA_REWARDED }, + onWatchAdWithReward = { action -> + adRewardAction = action + destination = AppDestination.ADSTERRA_REWARDED + }, onNavigateUrl = { url -> destination = AppDestination.MAIN_WEBVIEW sessionManager.navigateTo(url) }, screenTimeDao = database.screenTimeDao(), - reelHistory = reelHistory + reelHistory = reelHistory, + currentSubpage = settingsSubpage, + onSubpageChange = { settingsSubpage = it } ) } @@ -850,9 +1056,14 @@ fun FocusGramApp() { AppDestination.ADSTERRA_REWARDED -> { AdsterraAdScreen( - sessionType = if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary", + sessionType = if (adRewardAction != null) "setting_toggle" else if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary", onRewardClaimed = { - if (isDailyLimitExhausted) { + val pendingAction = adRewardAction + adRewardAction = null + if (pendingAction != null) { + scope.launch { pendingAction.invoke() } + destination = AppDestination.SETTINGS + } else if (isDailyLimitExhausted) { scope.launch { settingsRepository.extendDailyLimit5Minutes(today) } sessionRemainingSeconds += 5 * 60 sessionDurationMinutes += 5 @@ -872,6 +1083,7 @@ fun FocusGramApp() { } }, onCancel = { + adRewardAction = null if (isDailyLimitExhausted || isSessionExhausted) { destination = AppDestination.MAIN_WEBVIEW } else { @@ -960,6 +1172,9 @@ fun FocusGramApp() { private fun postSessionExpiredNotification(context: Context) { try { + // Direct physical vibration ensures the user feels the alert regardless of notification channel mutes + HapticHelper.performSessionCompletionAlert(context) + val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val channelId = "focusgram_session_alerts" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -988,7 +1203,7 @@ private fun postSessionExpiredNotification(context: Context) { val notification = NotificationCompat.Builder(context, channelId) .setContentTitle("Focus Session Expired") .setContentText("Your scheduled time limit has finished. Take a breath and step away.") - .setSmallIcon(R.mipmap.ic_launcher) + .setSmallIcon(R.drawable.ic_notification) .setPriority(NotificationCompat.PRIORITY_HIGH) .setDefaults(NotificationCompat.DEFAULT_ALL) .setAutoCancel(true) diff --git a/Ported/app/src/main/res/drawable/ic_notification.xml b/Ported/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 0000000..943547a --- /dev/null +++ b/Ported/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,9 @@ + + + diff --git a/Ported/app/src/main/res/xml/file_paths.xml b/Ported/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..791017d --- /dev/null +++ b/Ported/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt index c836826..613d55b 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt +++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusFeaturesEnhancementsTest.kt @@ -108,6 +108,21 @@ class FocusFeaturesEnhancementsTest { assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)")) } + @Test + fun testHapticHelperImplementsUniversalDirectHardwareVibration() { + val source = getFile("src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt").readText() + assertFalse("Must not use deprecated Context.VIBRATOR_SERVICE", source.contains("Context.VIBRATOR_SERVICE")) + assertTrue("Must use VibratorManager on API 31+", source.contains("VibratorManager::class.java")) + assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)")) + assertTrue("Must pulse physical vibrator directly with >= 30ms duration to overcome ERM motor inertia", + source.contains("createOneShot(35L") || source.contains("createOneShot(30L")) + assertTrue("Must provide session completion waveform pattern", source.contains("createWaveform(pattern")) + + val sessionExhaustedSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt").readText() + assertTrue("SessionExhaustedScreen must trigger session completion alert vibration", + sessionExhaustedSource.contains("HapticHelper.performSessionCompletionAlert")) + } + @Test fun testAdsterraDirectContainerAndPreloader() { val source = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt").readText() @@ -192,4 +207,281 @@ class FocusFeaturesEnhancementsTest { assertTrue("Native bottom nav bar in minimal mode must only display MESSAGES and PROFILE", navBarSource.contains("listOf(NavTab.MESSAGES, NavTab.PROFILE)")) } + + @Test + fun testFilePickerManifestAndProviderConfiguration() { + val manifestSource = getFile("src/main/AndroidManifest.xml").readText() + assertTrue("Manifest must declare FileProvider", + manifestSource.contains("androidx.core.content.FileProvider")) + assertTrue("Manifest must declare intent queries for PICK_IMAGES", + manifestSource.contains("android.provider.action.PICK_IMAGES")) + assertTrue("Manifest must declare intent queries for GET_CONTENT", + manifestSource.contains("android.intent.action.GET_CONTENT")) + assertTrue("Manifest must declare camera capture intents in queries", + manifestSource.contains("android.media.action.IMAGE_CAPTURE")) + + val pathsSource = getFile("src/main/res/xml/file_paths.xml").readText() + assertTrue("file_paths.xml must configure cache-path for camera", + pathsSource.contains("cache-path name=\"camera_photos\" path=\"camera/\"")) + } + + @Test + fun testNativeBottomNavBarBlurAndGlassStyling() { + val navBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt").readText() + assertTrue("NavBar must include frosted glass blur layer", + navBarSource.contains(".blur(20.dp)")) + assertTrue("NavBar must include glassBorderBrush", + navBarSource.contains("glassBorderBrush")) + assertTrue("NavBar must integrate HapticHelper for device-compatible vibrations", + navBarSource.contains("HapticHelper.performHapticFeedback") || navBarSource.contains("HapticHelper")) + } + + @Test + fun testFragment186VersionEnforcement() { + val buildGradle = getFile("build.gradle.kts").readText() + assertTrue("build.gradle.kts must force androidx.fragment:fragment:1.8.6", + buildGradle.contains("androidx.fragment:fragment:1.8.6")) + assertTrue("build.gradle.kts must force androidx.fragment:fragment-ktx:1.8.6", + buildGradle.contains("androidx.fragment:fragment-ktx:1.8.6")) + + val libsToml = getFile("../gradle/libs.versions.toml").readText() + assertTrue("libs.versions.toml must declare fragment = \"1.8.6\"", + libsToml.contains("fragment = \"1.8.6\"")) + } + + @Test + fun testFilePickerMimeParsingAndMediaRouting() { + fun resolveMediaTypes(mimes: Array, isProfilePictureEdit: Boolean = false): Triple { + val cleanMimes = mimes + .flatMap { it.split(",") } + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { token -> + if (token.startsWith(".jpg") || token.startsWith(".jpeg")) "image/jpeg" + else if (token.startsWith(".png")) "image/png" + else if (token.startsWith(".mp4")) "video/mp4" + else if (token.contains("/")) token + else null + } + .distinct() + .toTypedArray() + + val isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*") + val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") } + val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") } + + val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages) + val allowsImages = hasImages || isAnyMedia + + val visualMediaType = when { + allowsImages && !allowsVideos -> "ImageOnly" + allowsVideos && !allowsImages -> "VideoOnly" + else -> "ImageAndVideo" + } + return Triple(allowsImages, allowsVideos, visualMediaType) + } + + // 1. Profile Picture: /accounts/edit screen strictly image only + val (profileImg, profileVid, profileType) = resolveMediaTypes(arrayOf("image/*"), isProfilePictureEdit = true) + assertTrue(profileImg) + assertFalse(profileVid) + assertEquals("ImageOnly", profileType) + + // 2. DM / Story upload: Instagram sends only image MIME types, but we must allow both Images and Videos! + val (dmImg, dmVid, dmType) = resolveMediaTypes(arrayOf("image/avif, image/jpeg, image/png"), isProfilePictureEdit = false) + assertTrue(dmImg) + assertTrue(dmVid) + assertEquals("ImageAndVideo", dmType) + + val (storyImg, storyVid, storyType) = resolveMediaTypes(arrayOf("image/png, image/jpeg"), isProfilePictureEdit = false) + assertTrue(storyImg) + assertTrue(storyVid) + assertEquals("ImageAndVideo", storyType) + + // 3. Reels: Video only + val (reelsImg, reelsVid, reelsType) = resolveMediaTypes(arrayOf("video/mp4, video/quicktime"), isProfilePictureEdit = false) + assertFalse(reelsImg) + assertTrue(reelsVid) + assertEquals("VideoOnly", reelsType) + + // 4. Fallback: Empty array or */* + val (emptyImg, emptyVid, emptyType) = resolveMediaTypes(emptyArray(), isProfilePictureEdit = false) + assertTrue(emptyImg) + assertTrue(emptyVid) + assertEquals("ImageAndVideo", emptyType) + + val (starImg, starVid, starType) = resolveMediaTypes(arrayOf("*/*"), isProfilePictureEdit = false) + assertTrue(starImg) + assertTrue(starVid) + assertEquals("ImageAndVideo", starType) + } + + @Test + fun testNativeFeelIncludesVideoSupportBridge() { + val script = getFile("src/main/assets/scripts/native_feel.js").readText() + assertTrue("native_feel.js must include enhanceFileInputsForVideoSupport", + script.contains("enhanceFileInputsForVideoSupport")) + assertTrue("native_feel.js must broaden accept attribute to include video formats", + script.contains("video/mp4,video/quicktime,video/webm,video/*")) + assertTrue("native_feel.js must exclude /accounts/edit from video patching", + script.contains("/accounts/edit")) + } + + @Test + fun testReelsTopBarWatchedCount() { + val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText() + assertTrue("FocusGramApp must pass reelsWatchedSessionCount directly to ReelsTopBar", + appSource.contains("watchedCount = reelsWatchedSessionCount")) + + val topBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt").readText() + assertTrue("ReelsTopBar must default watchedCount to 0", + topBarSource.contains("watchedCount: Int = 0")) + } + + @Test + fun testDownloadProgressStateAndIndeterminateProgress() { + val defaultState = com.ujwal.focusgram.core.utils.DownloadProgressState() + assertFalse(defaultState.isDownloading) + assertFalse(defaultState.isCompleted) + assertEquals(0f, defaultState.progress, 0.001f) + + com.ujwal.focusgram.core.utils.MediaDownloadHelper.startIndeterminateProgress("test_reel") + val activeState = com.ujwal.focusgram.core.utils.MediaDownloadHelper.downloadProgressFlow.value + assertTrue("isDownloading must be true after startIndeterminateProgress", activeState.isDownloading) + assertFalse("isCompleted must be false while downloading", activeState.isCompleted) + assertTrue("progress should be >= 0.05f", activeState.progress >= 0.05f) + assertEquals("test_reel", activeState.filename) + } + + @Test + fun testHomeVsProfileTabResolutionWithQueryParams() { + // Simulates the exact logic in NativeBottomNavBar + fun getCleanPath(url: String): String { + val afterScheme = if (url.contains("://")) url.substringAfter("://") else url + val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else "" + return pathPart.substringBefore('?').substringBefore('#').trim('/') + } + + fun resolveIsHome(url: String): Boolean { + val cleanPath = getCleanPath(url) + val lowerUrl = url.lowercase() + val isHome = cleanPath.isEmpty() + return isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/" + } + + fun resolveIsProfile(url: String): Boolean { + val cleanPath = getCleanPath(url) + val lowerUrl = url.lowercase() + val isHome = cleanPath.isEmpty() + val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/") + val isExcluded = isHome || + lowerUrl.contains("/explore") || + lowerUrl.contains("/reel") || + lowerUrl.contains("/direct") || + lowerUrl.contains("/inbox") || + lowerUrl.contains("/p/") || + lowerUrl.contains("/stories/") || + lowerUrl.startsWith("focusgram-download:") || + lowerUrl.startsWith("javascript:") + + val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() } + val isSingleUserSegment = segments.size == 1 + + return !isHome && isInstagram && !isExcluded && + (lowerUrl.contains("/profile") || isSingleUserSegment) + } + + // Home URLs with parameters must always resolve to Home, NEVER Profile + assertTrue(resolveIsHome("https://www.instagram.com/")) + assertFalse(resolveIsProfile("https://www.instagram.com/")) + + assertTrue(resolveIsHome("https://www.instagram.com/?variant=home")) + assertFalse(resolveIsProfile("https://www.instagram.com/?variant=home")) + + assertTrue(resolveIsHome("https://www.instagram.com/?theme=dark")) + assertFalse(resolveIsProfile("https://www.instagram.com/?theme=dark")) + + // Profile URLs + assertFalse(resolveIsHome("https://www.instagram.com/zuck/")) + assertTrue(resolveIsProfile("https://www.instagram.com/zuck/")) + + assertFalse(resolveIsHome("https://www.instagram.com/profile")) + assertTrue(resolveIsProfile("https://www.instagram.com/profile")) + + // Excluded pages (Explore, Reels, DMs, Posts) + assertFalse(resolveIsProfile("https://www.instagram.com/explore/")) + assertFalse(resolveIsProfile("https://www.instagram.com/reel/C7x90/")) + assertFalse(resolveIsProfile("https://www.instagram.com/p/C7x90/")) + assertFalse(resolveIsProfile("https://www.instagram.com/direct/inbox/")) + } + + @Test + fun testFocusGramContentAntiFlashDoesNotCollapseFeed() { + val script = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText() + assertFalse( + "focusgram_content.js must NOT have overly broad div:has(> a[href='/']) that caused feed blanking", + script.contains("div:has(> a[href=\"/\"]):has(a[href*=\"/explore/\"])") + ) + assertFalse( + "focusgram_content.js must NOT have div:has(> div > a[href='/'])", + script.contains("div:has(> div > a[href=\"/\"]):has(a[href*=\"/explore/\"])") + ) + assertTrue( + "focusgram_content.js must restrict anti-flash hiding to navigation/footer roles", + script.contains("nav[role=\"navigation\"]:has(a[href*=\"/explore/\"])") + ) + } + + @Test + fun testMediaDownloaderDoesNotUseIframeToPreventCspViolations() { + val script = getFile("src/main/assets/scripts/media_downloader.js").readText() + assertFalse( + "media_downloader.js must not create iframes with custom schemes which violate Instagram frame-src CSP", + script.contains("document.createElement('iframe')") + ) + } + + @Test + fun testMediaDownloaderAccidentalScrollGuard() { + val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText() + assertTrue( + "media_downloader.js must guard against accidental download triggers during feed scrolling with hold timer", + scriptUser.contains("isGlobalScrolling") && scriptUser.contains("touchmove") && scriptUser.contains("holdThresholdPassed") + ) + } + + @Test + fun testMediaDownloaderPreventsFalseVideoHijackingOnPhotos() { + val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText() + assertTrue( + "media_downloader.js must check hasVideoElement before inspecting video streams from performance timings", + scriptUser.contains("hasVideoElement") + ) + } + + @Test + fun testMediaDownloadHelperPhotoVsVideoDetectionAndFilename() { + val helper = com.ujwal.focusgram.core.utils.MediaDownloadHelper + val photoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t51.82787-15/841260402_18131269318760808_4130815482121662762_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=107" + val videoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t50.28834-16/123456_n.mp4" + + assertFalse("Photo URL with /v/t51 and dst-jpg must NOT be detected as video", helper.detectIsVideo(photoUrl, "instagram_xyz.mp4")) + assertTrue("Video URL with /v/t50 and .mp4 must be detected as video", helper.detectIsVideo(videoUrl, "instagram_xyz.mp4")) + + val formattedPhotoName = helper.formatTargetFilename("@ujwal - Focusgram.mp4", false) + assertEquals("@ujwal - Focusgram.jpg", formattedPhotoName) + + val formattedVideoName = helper.formatTargetFilename("@ujwal - Focusgram.jpg", true) + assertEquals("@ujwal - Focusgram.mp4", formattedVideoName) + + val fallbackFormatted = helper.formatTargetFilename("instagram_DehM0IhkVm0.jpg", false) + assertEquals("@Focusgram - Focusgram.jpg", fallbackFormatted) + + val normalAuthorFormatted = helper.formatTargetFilename("@ujwal", true) + assertEquals("@ujwal - Focusgram.mp4", normalAuthorFormatted) + + val longAuthorFormatted = helper.formatTargetFilename("ujwaldeveloper12345", true) + assertEquals("@ujwaldevelope - Focusgram.mp4", longAuthorFormatted) + } } + diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt index 528943f..b80fdb7 100644 --- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt +++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/FocusSettingsTest.kt @@ -37,6 +37,8 @@ class FocusSettingsTest { // Initial user level assertEquals(1, settings.userLevel) assertEquals(0, settings.userXP) + // Portrait only enabled by default + assertTrue(settings.portraitOnly) } @Test diff --git a/Ported/gradle/libs.versions.toml b/Ported/gradle/libs.versions.toml index 29978f6..0b1dc40 100644 --- a/Ported/gradle/libs.versions.toml +++ b/Ported/gradle/libs.versions.toml @@ -12,6 +12,7 @@ room = "2.6.1" datastore = "1.1.2" securityCrypto = "1.1.0-alpha06" biometric = "1.2.0-alpha05" +fragment = "1.8.6" geckoview = "157.0.20260924084938" coroutines = "1.10.1" junit = "4.13.2" @@ -47,6 +48,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto # Security & Biometrics androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" } androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" } +androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } # Coroutines kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }