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" }