diff --git a/.gitignore b/.gitignore
index 8125860..89b4097 100644
--- a/.gitignore
+++ b/.gitignore
@@ -110,5 +110,7 @@ PRD.md
system/
Documents/
Ghostify/
+Ghostify
inspo/
+Instagram-Downloader/
diff --git a/Ported/.gitignore b/Ported/.gitignore
index 38ff876..9dd8e46 100644
--- a/Ported/.gitignore
+++ b/Ported/.gitignore
@@ -29,3 +29,7 @@ key.properties
Thumbs.db
*.log
*.hprof
+
+# Others
+Ghostify/
+Instagram-Downloader/
diff --git a/Ported/app/src/main/AndroidManifest.xml b/Ported/app/src/main/AndroidManifest.xml
index 33298b5..ab0dfb2 100644
--- a/Ported/app/src/main/AndroidManifest.xml
+++ b/Ported/app/src/main/AndroidManifest.xml
@@ -11,6 +11,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
text === kw || text.startsWith(kw) || text.includes(kw))) {
+ // Check post header only (Sponsored, Paid partnership)
+ const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]');
+ for (let j = 0; j < headerElements.length; j++) {
+ if (headerElements[j].children.length > 2) continue;
+ const text = (headerElements[j].textContent || '').trim().toLowerCase();
+ if (!text || text.length > 30) continue;
+ const clean = text.replace(/[^a-z]/g, '');
+ if (clean === 'ad' || SPONSORED_KEYWORDS.some(kw => text === kw || clean === kw)) {
isAd = true;
break;
}
}
- // Check CTA button text
+ // Check CTA button text specifically on buttons (excluding FocusGram download buttons)
if (!isAd) {
- const buttons = article.querySelectorAll('div[role="button"], a[role="button"]');
+ const buttons = article.querySelectorAll('div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), a[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), button');
for (let k = 0; k < buttons.length; k++) {
const btnText = (buttons[k].textContent || '').trim().toLowerCase();
- if (AD_CTA_KEYWORDS.some(cta => btnText === cta)) {
+ if (STRICT_AD_CTAS.some(cta => btnText === cta)) {
isAd = true;
break;
}
}
}
- // Check ad-specific links (strictly /ads/, /ad_, ads.instagram.com, /about/ads)
+ // Check ad-specific links (strictly /ads/, /ad_, ads.instagram.com)
if (!isAd) {
- if (article.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]')) {
+ if (article.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"]')) {
isAd = true;
}
}
@@ -120,42 +131,49 @@
}
}
- // Filter Reels video ads on reels and feed surfaces
+ // Filter Reels video ads on reels surface without ever breaking layout
const videos = document.querySelectorAll('video');
for (let i = 0; i < videos.length; i++) {
const video = videos[i];
- let container = video.closest('article') || video.closest('section') || video.closest('[role="region"]') || video.parentElement;
- while (container && container !== document.body && container !== document.documentElement) {
- if (container.tagName === 'ARTICLE' || container.tagName === 'SECTION' || container.parentElement?.tagName === 'MAIN') break;
- const r = container.getBoundingClientRect();
- if (r.height > window.innerHeight * 0.45) break;
- container = container.parentElement;
+ const card = video.closest('[data-fg-reel-card="1"]') || video.closest('article');
+ if (!card || card.dataset.adFiltered || card.dataset.fgReelAd) continue;
+
+ let isSponsored = !!card.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"], a[href*="/about/ads"]') ||
+ !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [data-ad-preview]');
+
+ if (!isSponsored) {
+ const badges = card.querySelectorAll('span, div[role="button"], button');
+ for (let s = 0; s < badges.length; s++) {
+ const el = badges[s];
+ if (el.children.length > 2) continue;
+ const raw = (el.textContent || '').trim();
+ if (!raw || raw.length > 15) continue;
+ if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) {
+ if (!el.closest('span[dir="auto"]')) {
+ isSponsored = true;
+ break;
+ }
+ }
+ }
}
- if (!container || container.dataset.adFiltered) continue;
- const text = (container.textContent || '').trim().toLowerCase();
- const isSponsored = SPONSORED_KEYWORDS.some(kw => text.includes(kw)) ||
- !!container.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]') ||
- !!container.querySelector('[aria-label*="Sponsored" i], [aria-label*="sponsored" i], [data-ad-preview]');
-
- const hasAdCTA = !!container.querySelector('div[role="button"], a[role="button"]') &&
- AD_CTA_KEYWORDS.some(cta => text.includes(cta));
-
- if (isSponsored || hasAdCTA) {
+ if (isSponsored) {
video.muted = true;
try { video.pause(); } catch (_) {}
- container.style.setProperty('display', 'none', 'important');
- container.style.setProperty('height', '0px', 'important');
- container.style.setProperty('opacity', '0', 'important');
- container.style.setProperty('pointer-events', 'none', 'important');
- container.dataset.adFiltered = 'true';
+ card.setAttribute('data-fg-reel-ad', '1');
+ card.dataset.adFiltered = 'true';
+ card.style.setProperty('visibility', 'hidden', 'important');
+ card.style.setProperty('pointer-events', 'none', 'important');
// Skip ad reel automatically if currently visible
- const r = container.getBoundingClientRect();
+ const r = card.getBoundingClientRect();
if (r.top >= -100 && r.top <= window.innerHeight * 0.8) {
- const evt = new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true });
- window.dispatchEvent(evt);
- document.dispatchEvent(evt);
+ const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]');
+ if (scroller && scroller !== window) {
+ try { scroller.scrollBy({ top: scroller.clientHeight || window.innerHeight, behavior: 'instant' }); } catch (_) {}
+ } else {
+ try { window.scrollBy({ top: window.innerHeight, behavior: 'instant' }); } catch (_) {}
+ }
}
}
}
@@ -168,6 +186,12 @@
}
});
+ setInterval(() => {
+ if (adBlockingEnabled) {
+ filterDynamicSponsoredPosts();
+ }
+ }, 300);
+
function init() {
updateCosmeticFilters();
const root = document.body || document.documentElement;
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 a593c59..7903d91 100644
--- a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js
+++ b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js
@@ -55,6 +55,7 @@
hideFollowerCounts: false,
hideSuggestedPosts: false,
hideShopTab: false,
+ videoDownloadEnabled: true,
ghostModeEnabled: false,
ghostHideStorySeen: true,
ghostHideDirectSeen: true,
@@ -77,6 +78,196 @@
hideTyping: true
};
+ window.__fgFilterConfig = {
+ blockAds: true,
+ blockSponsored: true,
+ blockSuggested: false,
+ blockVideos: false
+ };
+
+ window.addEventListener('message', function(e) {
+ if (!e || !e.data) return;
+ if (e.data.type === 'GHOSTIFY_SETTINGS_UPDATE' && e.data.settings) {
+ Object.assign(window.__focusgramGhostSettings, e.data.settings);
+ }
+ if (e.data.type === 'FOCUSGRAM_FILTER_UPDATE' && e.data.config) {
+ Object.assign(window.__fgFilterConfig, e.data.config);
+ }
+ });
+
+ const AD_KEYWORDS = [
+ 'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad',
+ 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung',
+ 'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags'
+ ];
+
+ function textHasAdSignal(value) {
+ if (!value) return false;
+ const s = String(value).toLowerCase();
+ return AD_KEYWORDS.some(kw => s.includes(kw));
+ }
+
+ 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
+ });
+
+ 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)
+ );
+ }
+
+ 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 || ''))
+ );
+ }
+
+ 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')
+ );
+ }
+
+ function shouldFilterNode(node) {
+ const config = window.__fgFilterConfig;
+ if (!config || !node || typeof node !== 'object') return false;
+ if (config.blockAds !== false && isAdNode(node)) return true;
+ if (config.blockSponsored !== false && isSponsoredNode(node) && !isAdNode(node)) return true;
+ if (config.blockSuggested && isSuggestedNode(node)) return true;
+ return false;
+ }
+
+ function unwrapNode(item) {
+ if (!item || typeof item !== 'object') return item;
+ return item.media || item.node || item.post || item;
+ }
+
+ function filterList(list) {
+ if (!Array.isArray(list)) return list;
+ return list.filter(item => {
+ if (!item) return false;
+ const target = unwrapNode(item);
+ return !shouldFilterNode(target);
+ });
+ }
+
+ function harvestReelFromObj(node) {
+ if (!node || typeof node !== 'object') return;
+ 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 || '';
+ let caption = '';
+ if (typeof node.caption === 'string') {
+ caption = node.caption;
+ } else if (node.caption && typeof node.caption.text === 'string') {
+ caption = node.caption.text;
+ } 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 (_) {}
+ }
+ }
+ }
+
+ function walkAndFilter(obj, visited = new Set()) {
+ if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
+ visited.add(obj);
+
+ harvestReelFromObj(obj);
+
+ if (Array.isArray(obj)) {
+ for (let i = 0; i < obj.length; i++) {
+ walkAndFilter(obj[i], visited);
+ }
+ return;
+ }
+
+ if (obj.edges && Array.isArray(obj.edges)) {
+ obj.edges = filterList(obj.edges);
+ }
+ if (obj.items && Array.isArray(obj.items)) {
+ obj.items = filterList(obj.items);
+ }
+ if (obj.feed_items && Array.isArray(obj.feed_items)) {
+ obj.feed_items = filterList(obj.feed_items);
+ }
+ if (obj.tray && Array.isArray(obj.tray)) {
+ obj.tray = filterList(obj.tray);
+ }
+
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') {
+ const val = obj[key];
+ if (val && typeof val === 'object') {
+ walkAndFilter(val, visited);
+ }
+ }
+ }
+ }
+
const STORY_PATTERNS = [
'storiesupdateseenmutation',
'polarisstoriesseenmutation',
@@ -278,7 +469,28 @@
});
}
- return originalFetch.apply(this, arguments);
+ const response = await originalFetch.apply(this, arguments);
+
+ // Main-World GraphQL, Clips, & Feed Ad Filtering & Reel Meta Harvesting
+ if (url.includes('/graphql') || url.includes('/api/v1/feed') || url.includes('/api/v1/clips/') || url.includes('/clips/')) {
+ try {
+ const contentType = response.headers.get('content-type') || '';
+ if (contentType.includes('application/json')) {
+ const cloned = response.clone();
+ const data = await cloned.json();
+ walkAndFilter(data);
+ if (window.__fgFilterConfig && window.__fgFilterConfig.blockAds !== false) {
+ return new Response(JSON.stringify(data), {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers
+ });
+ }
+ }
+ } catch (_) {}
+ }
+
+ return response;
};
// --- Hook navigator.sendBeacon ---
@@ -484,6 +696,15 @@
igStory: isGhostActive && !!currentSettings.ghostHideStorySeen
}
}, "*");
+ window.postMessage({
+ type: "FOCUSGRAM_FILTER_UPDATE",
+ source: "FOCUSGRAM_EXTENSION",
+ config: {
+ blockAds: currentSettings.noAds !== false,
+ blockSponsored: currentSettings.noAds !== false,
+ blockSuggested: !!currentSettings.hideSuggestedPosts
+ }
+ }, "*");
}
window.addEventListener("message", (event) => {
@@ -552,7 +773,7 @@
--fg-reel-scrubber-offset: 2px;
}
- /* Reels fixed scroller: Remove Instagram's 45px bottom navigation reservation */
+ /* Reels fixed scroller: Ultra-smooth momentum scrolling & snapping */
[data-fg-reel-scroller],
div[style*="position: fixed"][style*="overflow-y: scroll"],
div[style*="position: fixed"][style*="overflow-y: auto"],
@@ -560,6 +781,13 @@
bottom: 0px !important;
height: 100% !important;
max-height: none !important;
+ scroll-snap-type: y mandatory !important;
+ scroll-snap-stop: normal !important;
+ overscroll-behavior-y: contain !important;
+ -webkit-overflow-scrolling: touch !important;
+ touch-action: pan-y !important;
+ will-change: scroll-position !important;
+ contain: strict !important;
}
/* Container full-bleed lock for Reels */
@@ -577,7 +805,7 @@
background-color: #000000 !important;
}
- /* Each Reel Card & Video Container */
+ /* Each Reel Card & Video Container: GPU accelerated layer & layout isolation */
[data-fg-reel-card],
[data-fg-reel],
html[data-fg-island="FLOATING"] main section,
@@ -590,6 +818,11 @@
position: relative !important;
box-sizing: border-box !important;
background-color: #000000 !important;
+ scroll-snap-align: start !important;
+ scroll-snap-stop: normal !important;
+ contain: layout paint !important;
+ will-change: transform !important;
+ transform: translateZ(0) !important;
}
/* Video scaling: seamless edge-to-edge presentation */
@@ -962,7 +1195,370 @@
- // --- 3. REELS LAYOUT SCROLLER & OVERLAY POSITIONING (100% INSPIRATION MATCH) ---
+ // --- 3. REELS INTERACTION-BASED COUNTER & HISTORY TRACKER ---
+ let lastTrackedReelCode = null;
+ let lastTrackedCaption = '';
+ let lastTrackedAuthor = '';
+ const harvestedReels = new Map();
+
+ function handleReelMetaEvent(raw) {
+ try {
+ const data = typeof raw === 'string' ? JSON.parse(raw) : raw;
+ if (data && data.code) {
+ const existing = harvestedReels.get(data.code) || {};
+ const updated = {
+ author: data.author || existing.author || '',
+ caption: data.caption || existing.caption || ''
+ };
+ harvestedReels.set(data.code, updated);
+
+ if (lastTrackedReelCode === data.code) {
+ let changed = false;
+ if (!lastTrackedAuthor && updated.author) {
+ lastTrackedAuthor = updated.author;
+ changed = true;
+ }
+ if (!lastTrackedCaption && updated.caption) {
+ lastTrackedCaption = updated.caption;
+ changed = true;
+ }
+ if (changed) {
+ postToNative({
+ action: 'reelWatched',
+ type: 'meta',
+ code: data.code,
+ url: 'https://www.instagram.com/reel/' + data.code + '/',
+ title: lastTrackedCaption || (lastTrackedAuthor ? ('Reel by @' + lastTrackedAuthor) : ''),
+ caption: lastTrackedCaption,
+ author: lastTrackedAuthor,
+ thumbnail: ''
+ });
+ }
+ }
+ }
+ } catch (_) {}
+ }
+
+ document.addEventListener('focusgram:reel-meta', (e) => handleReelMetaEvent(e.detail), true);
+ window.addEventListener('focusgram:reel-meta', (e) => handleReelMetaEvent(e.detail), true);
+
+ function findReelCardForVideo(v) {
+ if (!v) return null;
+ const article = v.closest('article, section, [role="region"], [data-fg-reel-card]');
+ if (article && article.querySelectorAll('video').length <= 1) {
+ return article;
+ }
+
+ let p = v.parentElement;
+ let best = null;
+ while (p && p !== document.body && p !== document.documentElement && p.tagName !== 'MAIN') {
+ if (p.querySelectorAll('video').length > 1) {
+ break;
+ }
+ if (p.querySelector('a[href*="/audio/"]') || p.querySelector('header') || p.querySelector('a[role="link"]:not([href*="/audio/"])')) {
+ best = p;
+ }
+ p = p.parentElement;
+ }
+ return best || article || v.parentElement;
+ }
+
+ function checkAndTrackActiveReel() {
+ const path = window.location.pathname || '';
+ // Only run when user is on Reels or viewing a Reel
+ if (!path.startsWith('/reels') && !path.startsWith('/reel')) {
+ return;
+ }
+
+ let code = null;
+
+ // 1. Direct URL check: /reel/ or /reels/
+ const urlMatch = path.match(/\/(?:reel|reels)\/([A-Za-z0-9_-]{4,})/);
+ if (urlMatch && !['reels', 'reel', 'audio', 'videos', 'clips', 'explore', 'home', 'direct', 'stories'].includes(urlMatch[1].toLowerCase())) {
+ code = urlMatch[1];
+ }
+
+ const vh = window.innerHeight || 800;
+ let activeCard = null;
+
+ // 2. Identify the active reel card
+ // Priority A: If code is known from URL, find card containing a link with that code
+ if (code) {
+ const codeLink = document.querySelector(`a[href*="/reel/${code}/"], a[href*="/reels/${code}/"], a[href*="/p/${code}/"]`);
+ if (codeLink) {
+ activeCard = findReelCardForVideo(codeLink);
+ }
+ }
+
+ // Priority B: Find video actively playing in viewport
+ const videos = Array.from(document.querySelectorAll('video'));
+ if (!activeCard) {
+ for (const v of videos) {
+ const rect = v.getBoundingClientRect();
+ if (rect.bottom > 50 && rect.top < vh - 50 && !v.paused) {
+ activeCard = findReelCardForVideo(v);
+ break;
+ }
+ }
+ }
+
+ // Priority C: Center-most visible video in viewport
+ if (!activeCard) {
+ let minDistance = Infinity;
+ for (const v of videos) {
+ const rect = v.getBoundingClientRect();
+ if (rect.bottom > 0 && rect.top < vh && rect.height > 100) {
+ const centerY = rect.top + rect.height / 2;
+ const dist = Math.abs(centerY - vh / 2);
+ if (dist < minDistance) {
+ minDistance = dist;
+ activeCard = findReelCardForVideo(v);
+ }
+ }
+ }
+ }
+
+ if (!activeCard && videos.length > 0) {
+ activeCard = findReelCardForVideo(videos[0]);
+ }
+
+ // Extract code from activeCard if not in URL
+ if (!code && activeCard) {
+ const reelLinks = Array.from(activeCard.querySelectorAll('a[href*="/reel/"], a[href*="/reels/"], a[href*="/p/"]'));
+ for (const l of reelLinks) {
+ const href = l.getAttribute('href') || '';
+ if (href.includes('/audio/')) continue;
+ const m = href.match(/\/(?:reel|reels|p)\/([A-Za-z0-9_-]{4,})/);
+ if (m && !['reels', 'reel', 'audio', 'videos', 'clips', 'explore', 'home', 'direct', 'stories'].includes(m[1].toLowerCase())) {
+ code = m[1];
+ break;
+ }
+ }
+ }
+
+ // If no valid reel shortcode found, DO NOT track
+ if (!code || code === 'reels' || code === 'reel' || code === 'audio') return;
+
+ // If this reel card is marked as an ad, DO NOT count or log it
+ if (activeCard) {
+ if (activeCard.hasAttribute('data-fg-reel-ad') || activeCard.getAttribute('data-fg-ad') === '1') {
+ return;
+ }
+ const adPill = activeCard.querySelector('[aria-label="Ad"], a[href*="/ads/"], a[href*="/ad_"]');
+ if (adPill) return;
+ }
+
+ let author = '';
+ let caption = '';
+
+ // Source 1: Check memory cache from previous events
+ if (code && harvestedReels.has(code)) {
+ const h = harvestedReels.get(code);
+ if (h.author) author = h.author;
+ if (h.caption) caption = h.caption.slice(0, 300);
+ }
+
+ // Source 2: Check hidden DOM cache element (#fg-meta-cache)
+ if (code && (!author || !caption)) {
+ const cacheEl = document.getElementById('fg-meta-cache');
+ if (cacheEl && cacheEl.hasAttribute('data-reel-' + code)) {
+ try {
+ const cached = JSON.parse(cacheEl.getAttribute('data-reel-' + code));
+ if (!author && cached.author) author = cached.author;
+ if (!caption && cached.caption) caption = cached.caption.slice(0, 300);
+ } catch (_) {}
+ }
+ }
+
+ // Source 3: Check meta tags (OG description & meta description)
+ if (!caption || !author) {
+ const metaDesc = document.querySelector('meta[property="og:description"], meta[name="description"]');
+ if (metaDesc && metaDesc.content) {
+ const m = metaDesc.content.match(/-\s*([A-Za-z0-9._]+)\s+on\s+[^:]+:\s*["“](.*?)["”]/s);
+ if (m) {
+ if (!author && m[1]) author = m[1].trim();
+ if (!caption && m[2]) caption = m[2].trim().slice(0, 300);
+ }
+ }
+ }
+
+ // Source 4: Check document.title ("Author on Instagram: \"Caption\"")
+ if (!caption || !author) {
+ if (document.title) {
+ const m = document.title.match(/^([^:]+?)\s+on\s+Instagram:\s*["“](.*?)["”]/s);
+ if (m) {
+ if (!author && m[1]) author = m[1].trim();
+ if (!caption && m[2]) caption = m[2].trim().slice(0, 300);
+ }
+ }
+ }
+
+ // Source 5: Extract Author from activeCard DOM
+ if (!author && activeCard) {
+ const avatarLink = activeCard.querySelector('header a:has(img), a[href^="/"]:has(img[alt*="profile" i]), a[href^="/"]:has(img)');
+ if (avatarLink) {
+ const href = (avatarLink.getAttribute('href') || '').replace(/^\/+|\/+$/g, '').split('/')[0];
+ if (href && !['home', 'explore', 'reels', 'reel', 'direct', 'stories', 'accounts', 'legal', 'about', 'audio'].includes(href.toLowerCase())) {
+ author = href;
+ }
+ }
+ if (!author) {
+ const headerLink = activeCard.querySelector('header a[href^="/"], [role="region"] header a[href^="/"]');
+ if (headerLink) {
+ const href = (headerLink.getAttribute('href') || '').replace(/^\/+|\/+$/g, '').split('/')[0];
+ if (href && !['home', 'explore', 'reels', 'reel', 'direct', 'stories', 'accounts', 'legal', 'about', 'audio'].includes(href.toLowerCase())) {
+ author = href;
+ }
+ if (!author) {
+ const text = (headerLink.textContent || '').trim().replace(/^@/, '');
+ if (text && !['home', 'explore', 'reels', 'direct', 'instagram'].includes(text.toLowerCase())) {
+ author = text;
+ }
+ }
+ }
+ }
+ if (!author) {
+ const links = Array.from(activeCard.querySelectorAll('a[href^="/"]:not([href*="/audio/"]):not([href*="/reel/"]):not([href*="/reels/"]):not([href*="/explore/"]):not([href*="/tags/"])'));
+ for (const a of links) {
+ if (a.closest('a[href*="/audio/"]')) continue;
+ const rawHref = (a.getAttribute('href') || '').replace(/^\/+|\/+$/g, '').split('/')[0];
+ const lower = rawHref.toLowerCase();
+ if (rawHref && !['home', 'explore', 'reels', 'reel', 'direct', 'stories', 'p', 'accounts', 'legal', 'about', 'audio'].includes(lower)) {
+ author = rawHref;
+ break;
+ }
+ }
+ }
+ }
+
+ // Source 6: Extract Caption from activeCard DOM
+ if (!caption && activeCard) {
+ const candidates = Array.from(activeCard.querySelectorAll('h1, h2, span[dir="auto"], div[dir="auto"], p, span'));
+ for (const el of candidates) {
+ if (el.closest('a[href*="/audio/"]')) continue;
+
+ let txt = (el.textContent || '').trim();
+ if (!txt || txt.length < 2) continue;
+
+ // Strip author username if prepended at start of caption
+ if (author) {
+ const cleanAuthor = author.toLowerCase().replace(/^@/, '');
+ if (txt.toLowerCase().startsWith(cleanAuthor)) {
+ txt = txt.slice(cleanAuthor.length).trim().replace(/^[:\-–—\s]+/, '').trim();
+ } else if (txt.toLowerCase().startsWith('@' + cleanAuthor)) {
+ txt = txt.slice(cleanAuthor.length + 1).trim().replace(/^[:\-–—\s]+/, '').trim();
+ }
+ }
+
+ const lower = txt.toLowerCase();
+ if (txt.length < 2) continue;
+
+ // Ignore if text is purely the author name
+ if (author && (lower === author.toLowerCase() || lower === '@' + author.toLowerCase())) continue;
+
+ // Ignore action buttons, counts, and UI badges
+ if (/^(?:follow|following|requested|watch\s+more|view\s+profile|sign\s+up|install\s+now|learn\s+more|shop\s+now|more|\.\.\.\s*more)$/i.test(lower)) continue;
+ if (lower === 'original audio' || lower.includes('· original audio')) continue;
+ if (/^\d+[\d,.]*[KkMm]?$/.test(txt)) continue;
+ if (lower.startsWith('liked by ') || lower.startsWith('verified')) continue;
+ if (/^(?:ad|sponsored|paid\s+partnership)$/i.test(lower)) continue;
+ if (/^(?:like|comment|comments|share|save|remix|send|audio|music|tagged|explore|reels|reel)$/i.test(lower)) continue;
+
+ caption = txt.slice(0, 300);
+ break;
+ }
+ }
+
+ // Cache whatever we resolved
+ if (code && (author || caption)) {
+ const existing = harvestedReels.get(code) || {};
+ harvestedReels.set(code, {
+ author: author || existing.author || '',
+ caption: caption || existing.caption || ''
+ });
+ }
+
+ const reelUrl = 'https://www.instagram.com/reel/' + code + '/';
+
+ // If code hasn't changed, check if metadata became available later
+ if (code === lastTrackedReelCode) {
+ if ((!lastTrackedCaption && caption) || (!lastTrackedAuthor && author)) {
+ if (caption) lastTrackedCaption = caption;
+ if (author) lastTrackedAuthor = author;
+ postToNative({
+ action: 'reelWatched',
+ type: 'meta',
+ code: code,
+ url: reelUrl,
+ title: lastTrackedCaption || (lastTrackedAuthor ? ('Reel by @' + lastTrackedAuthor) : ''),
+ caption: lastTrackedCaption,
+ author: lastTrackedAuthor,
+ thumbnail: ''
+ });
+ }
+ return;
+ }
+
+ // CODE CHANGED! Update last tracked state
+ lastTrackedReelCode = code;
+ lastTrackedCaption = caption;
+ lastTrackedAuthor = author;
+
+ postToNative({
+ action: 'reelWatched',
+ code: code,
+ url: reelUrl,
+ title: caption || (author ? ('Reel by @' + author) : ''),
+ caption: caption,
+ author: author || '',
+ thumbnail: ''
+ });
+ }
+
+ // Attach interaction listeners for Reels tracking without thrashing scroll frames
+ let checkReelDebounceTimer = null;
+ function debouncedCheckAndTrackActiveReel() {
+ if (checkReelDebounceTimer) clearTimeout(checkReelDebounceTimer);
+ checkReelDebounceTimer = setTimeout(checkAndTrackActiveReel, 120);
+ }
+
+ window.addEventListener('scroll', debouncedCheckAndTrackActiveReel, { passive: true });
+ window.addEventListener('scrollend', checkAndTrackActiveReel, { passive: true });
+ window.addEventListener('touchend', () => setTimeout(checkAndTrackActiveReel, 100), { passive: true });
+ window.addEventListener('wheel', debouncedCheckAndTrackActiveReel, { passive: true });
+ window.addEventListener('keydown', (e) => {
+ if (['ArrowDown', 'ArrowUp', 'PageDown', 'PageUp', 'Space'].includes(e.key)) {
+ setTimeout(checkAndTrackActiveReel, 120);
+ }
+ });
+ window.addEventListener('popstate', checkAndTrackActiveReel);
+ window.addEventListener('hashchange', checkAndTrackActiveReel);
+ window.addEventListener('focusgram:urlchange', checkAndTrackActiveReel);
+
+ // Periodic ticker (every 400ms) to ensure reel transitions are never missed
+ setInterval(checkAndTrackActiveReel, 400);
+
+ // Ensure clicking 'Post' or 'Story' in the Create dropdown opens file/media picker
+ document.addEventListener('click', (e) => {
+ const target = e.target;
+ if (!target) return;
+
+ const postOrStoryBtn = target.closest('button, div[role="button"], a[role="button"], li, div[role="menuitem"]');
+ if (postOrStoryBtn) {
+ const text = (postOrStoryBtn.textContent || '').trim().toLowerCase();
+ if (text === 'post' || text === 'story' || text.startsWith('post\n') || text.startsWith('story\n')) {
+ const fileInput = postOrStoryBtn.querySelector('input[type="file"]') ||
+ postOrStoryBtn.parentElement?.querySelector('input[type="file"]') ||
+ document.querySelector('form input[type="file"]') ||
+ document.querySelector('input[type="file"]');
+ if (fileInput && fileInput !== target) {
+ fileInput.click();
+ }
+ }
+ }
+ }, true);
+
+ // --- 3b. REELS LAYOUT SCROLLER & OVERLAY POSITIONING (100% INSPIRATION MATCH) ---
function fixReels() {
const path = window.location.pathname.toLowerCase();
if (!path.startsWith('/reels') && !path.startsWith('/reel')) return;
@@ -998,49 +1594,51 @@
v.style.setProperty('height', '100%', 'important');
// Find closest reel card container
- let card = v.closest('article') || v.closest('section') || v.closest('[role="region"]') || v.parentElement;
- while (card && card !== document.body && card !== document.documentElement) {
- if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION' || card.parentElement?.tagName === 'MAIN') {
- break;
- }
- const r = card.getBoundingClientRect();
- if (r.height > vh * 0.5) {
- break;
- }
- card = card.parentElement;
+ let card = findReelCardForVideo(v);
+ if (!card) return;
+ // Prevent selecting root page or scroller containers holding multiple reels
+ if (card.tagName === 'MAIN' || card.id === 'react-root' || card.querySelectorAll('video').length > 1) {
+ return;
}
- if (!card) return;
+ if (!card.hasAttribute('data-fg-reel-card')) {
+ card.setAttribute('data-fg-reel-card', '1');
+ }
// Check if this reel is an ad / sponsored
if (currentSettings.noAds !== false) {
- const cardText = (card.textContent || '').toLowerCase();
- const isSponsored = cardText.includes('sponsored') ||
- cardText.includes('paid partnership') ||
- cardText.includes('gesponsert') ||
- cardText.includes('sponsorisé') ||
- cardText.includes('publicidad') ||
- cardText.includes('patrocinado') ||
- cardText.includes('sponsorizzato') ||
- !!card.querySelector('a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]') ||
- !!card.querySelector('[aria-label*="Sponsored" i], [aria-label*="sponsored" i], [data-ad-preview]');
+ let isSponsored = !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [aria-label*="advertisement" i], [data-ad-preview], a[href*="/ads/"], a[href*="/ad_"], a[href*="/about/ads"], a[href*="ads.instagram.com"]');
- const hasAdCTA = !!card.querySelector('div[role="button"], a[role="button"]') &&
- ['learn more', 'shop now', 'install now', 'sign up', 'apply now', 'book now', 'download', 'order now', 'get offer', 'subscribe', 'contact us']
- .some(cta => cardText.includes(cta));
+ if (!isSponsored) {
+ const badges = card.querySelectorAll('span, div[role="button"], button');
+ for (let s = 0; s < badges.length; s++) {
+ const el = badges[s];
+ if (el.children.length > 2) continue;
+ const raw = (el.textContent || '').trim();
+ if (!raw || raw.length > 15) continue;
+ if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) {
+ if (!el.closest('span[dir="auto"]')) {
+ isSponsored = true;
+ break;
+ }
+ }
+ }
+ }
- if (isSponsored || hasAdCTA) {
+ if (isSponsored) {
card.setAttribute('data-fg-reel-ad', '1');
- card.style.setProperty('display', 'none', 'important');
- card.style.setProperty('height', '0px', 'important');
- card.style.setProperty('opacity', '0', 'important');
+ card.style.setProperty('visibility', 'hidden', 'important');
card.style.setProperty('pointer-events', 'none', 'important');
v.muted = true;
try { v.pause(); } catch (_) {}
-
- const rect = card.getBoundingClientRect();
- if (rect.top >= -100 && rect.top <= window.innerHeight * 0.8) {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true }));
+ const vr = v.getBoundingClientRect();
+ if (vr.top >= -vh * 0.4 && vr.bottom <= vh * 1.4) {
+ const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]');
+ if (scroller && scroller !== window) {
+ try { scroller.scrollBy({ top: scroller.clientHeight || vh, behavior: 'instant' }); } catch (_) {}
+ } else {
+ try { window.scrollBy({ top: vh, behavior: 'instant' }); } catch (_) {}
+ }
}
return;
}
@@ -1150,10 +1748,13 @@
window.addEventListener('popstate', fixReels);
window.addEventListener('focusgram:urlchange', fixReels);
- // Capture scroll events anywhere in document
+ // Capture scroll events anywhere in document without blocking smooth 60/120fps compositor
+ let fixReelsScrollTimer = null;
document.addEventListener('scroll', () => {
- fixReels();
+ if (fixReelsScrollTimer) clearTimeout(fixReelsScrollTimer);
+ fixReelsScrollTimer = setTimeout(fixReels, 150);
}, { passive: true, capture: true });
+ window.addEventListener('scrollend', fixReels, { passive: true });
let reelTimeThrottle = null;
document.addEventListener('timeupdate', () => {
@@ -1444,8 +2045,8 @@
action: 'reelViewed',
type: 'reelViewed',
url: url,
- title: title || 'Instagram Reel',
- author: author || 'Instagram Creator',
+ title: title || '',
+ author: author || '',
thumbnailUrl: thumbnailUrl || ''
});
console.log('[FocusGram] In-page extracted reel metadata:', url);
@@ -1727,31 +2328,456 @@
} catch (_) {}
}
+ const FG_ACT_ATTR = 'data-fg-x-act';
+ const FG_ACT_CLASS = 'fg-x-act-btn';
+
+ 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 extractVideoUrlFromFiber(rawNode) {
+ if (!rawNode) return null;
+ try {
+ const node = rawNode.wrappedJSObject || rawNode;
+ 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) {
+ 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;
+ }
+ if (props.media && props.media.video_versions && props.media.video_versions[0]?.url) {
+ return props.media.video_versions[0].url;
+ }
+ if (props.item && props.item.video_versions && props.item.video_versions[0]?.url) {
+ return props.item.video_versions[0].url;
+ }
+ if (props.clip && props.clip.video_versions && props.clip.video_versions[0]?.url) {
+ return props.clip.video_versions[0].url;
+ }
+ if (props.post && props.post.video_versions && props.post.video_versions[0]?.url) {
+ return props.post.video_versions[0].url;
+ }
+ }
+ fiber = fiber.return;
+ }
+ } catch (_) {}
+ return null;
+ }
+
+ 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 vSrc = video.src || video.currentSrc || '';
+ if (vSrc && (vSrc.startsWith('http://') || vSrc.startsWith('https://')) && !vSrc.startsWith('blob:')) {
+ return { url: vSrc, isVideo: true };
+ }
+ const source = video.querySelector('source');
+ 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 };
+ }
+
+ // 2. Image check (best candidate from srcset or src)
+ 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, isVideo: false };
+ }
+ }
+ const iSrc = mainImg.currentSrc || mainImg.src;
+ if (iSrc && (iSrc.startsWith('http://') || iSrc.startsWith('https://'))) {
+ return { url: iSrc, isVideo: false };
+ }
+ }
+
+ // 3. Meta og:video & og:image
+ 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://'))) {
+ return { url: ogVideo.content, isVideo: true };
+ }
+ 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 };
+ }
+ // 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);
+ }
+ });
+ }
+
+ function filterFeedAds() {
+ if (currentSettings.noAds === false) return;
+ const SPONSORED_KW = [
+ 'sponsored', 'gesponsert', 'sponsorisé', 'publicidad',
+ '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'
+ ];
+
+ document.querySelectorAll('article').forEach(article => {
+ if (article.dataset.fgAdFiltered) return;
+ let isAd = !!article.querySelector('[data-ad-preview], a[href*="/ads/"], a[href*="/ad_"], a[href*="ads.instagram.com"]');
+
+ if (!isAd) {
+ const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]');
+ for (let i = 0; i < headerElements.length; i++) {
+ const el = headerElements[i];
+ if (el.children.length > 2) continue;
+ const txt = (el.textContent || '').trim().toLowerCase();
+ if (!txt || txt.length > 30) continue;
+ const clean = txt.replace(/[^a-z]/g, '');
+ if (clean === 'ad' || SPONSORED_KW.some(kw => txt === kw || clean === kw)) {
+ isAd = true;
+ break;
+ }
+ }
+ }
+
+ if (!isAd) {
+ const buttons = article.querySelectorAll('div[role="button"]:not([' + FG_ACT_ATTR + ']):not([data-fg-download-btn]), a[role="button"]:not([' + FG_ACT_ATTR + ']):not([data-fg-download-btn]), button');
+ for (let k = 0; k < buttons.length; k++) {
+ const btnTxt = (buttons[k].textContent || '').trim().toLowerCase();
+ if (STRICT_AD_CTAS.some(cta => btnTxt === cta)) {
+ isAd = true;
+ break;
+ }
+ }
+ }
+
+ 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('overflow', 'hidden', 'important');
+ article.style.setProperty('opacity', '0', 'important');
+ article.style.setProperty('pointer-events', 'none', '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) });
+ }
+ }
+
// 250ms periodic heartbeat guarantees zero missed state changes during React renders
setInterval(() => {
checkStateAndNotify();
hideNav();
updateShareSheetState();
fixReels();
+ filterFeedAds();
cleanUpDynamicContent();
+ syncDownloadButtons();
}, 250);
- // --- CREATE (+) DROPDOWN "POST" & "STORY" HANDLER ---
- document.addEventListener('click', (e) => {
- try {
- const target = e.target;
- if (!target) return;
- const text = (target.textContent || '').trim();
- if (text === 'Post' || text === 'Story') {
- const container = target.closest('form') || target.closest('[role="dialog"]') || target.closest('[role="menu"]') || document;
- const fileInput = container.querySelector('input[type="file"]');
- if (fileInput && fileInput !== target) {
- fileInput.click();
- }
- }
- } catch (_) {}
- }, true);
-
// --- 4. INPUT FOCUS / TYPING BRIDGE ---
function setupInputFocusListeners() {
document.addEventListener('focusin', (e) => {
@@ -1869,6 +2895,9 @@
document.querySelector('svg[aria-label="New Post"]')?.closest('a, button, div[role="button"]');
if (createBtn) createBtn.click();
return;
+ } else if (msg.action === 'requestReelDownload') {
+ downloadActiveReel();
+ return;
}
}
@@ -1891,6 +2920,7 @@
hideFollowerCounts: config.hideFollowerCounts !== undefined ? config.hideFollowerCounts : currentSettings.hideFollowerCounts,
hideSuggestedPosts: config.hideSuggestedPosts !== undefined ? config.hideSuggestedPosts : currentSettings.hideSuggestedPosts,
hideShopTab: config.hideShopTab !== undefined ? config.hideShopTab : currentSettings.hideShopTab,
+ videoDownloadEnabled: config.videoDownloadEnabled !== undefined ? config.videoDownloadEnabled : currentSettings.videoDownloadEnabled,
grayscale: config.grayscale !== undefined ? config.grayscale : currentSettings.grayscale,
minimalMode: config.minimalMode !== undefined ? config.minimalMode : currentSettings.minimalMode,
ghostModeEnabled: ghost.enabled !== undefined ? ghost.enabled : currentSettings.ghostModeEnabled,
@@ -2100,7 +3130,13 @@
} catch (_) {}
}
- window.addEventListener('scroll', checkAndTriggerInfiniteScroll, { passive: true });
+ let infiniteScrollTimer = null;
+ window.addEventListener('scroll', () => {
+ if (infiniteScrollTimer) clearTimeout(infiniteScrollTimer);
+ infiniteScrollTimer = setTimeout(checkAndTriggerInfiniteScroll, 180);
+ }, { passive: true });
+ window.addEventListener('scrollend', checkAndTriggerInfiniteScroll, { passive: true });
+ window.addEventListener('focusgram:download-reel', downloadActiveReel);
})();
diff --git a/Ported/app/src/main/assets/extensions/ublock/manifest.json b/Ported/app/src/main/assets/extensions/ublock/manifest.json
index bd4c006..7fc2370 100644
--- a/Ported/app/src/main/assets/extensions/ublock/manifest.json
+++ b/Ported/app/src/main/assets/extensions/ublock/manifest.json
@@ -22,17 +22,6 @@
"persistent": true
},
"content_scripts": [
- {
- "matches": [
- "*://*.instagram.com/*"
- ],
- "js": [
- "ghost.js"
- ],
- "run_at": "document_start",
- "world": "MAIN",
- "all_frames": true
- },
{
"matches": [
"*://*.instagram.com/*"
@@ -45,6 +34,9 @@
"all_frames": true
}
],
+ "web_accessible_resources": [
+ "ghost.js"
+ ],
"browser_specific_settings": {
"gecko": {
"id": "uBlock0@raymondhill.net",
diff --git a/Ported/app/src/main/assets/reelhistory/bg.js b/Ported/app/src/main/assets/reelhistory/bg.js
new file mode 100644
index 0000000..454d3ea
--- /dev/null
+++ b/Ported/app/src/main/assets/reelhistory/bg.js
@@ -0,0 +1,36 @@
+let nativePort = null;
+
+function getPort() {
+ if (!nativePort) {
+ try {
+ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.connectNative) {
+ nativePort = browser.runtime.connectNative('reelhistory');
+ nativePort.onDisconnect.addListener(() => {
+ nativePort = null;
+ });
+ }
+ } catch (_) {
+ nativePort = null;
+ }
+ }
+ return nativePort;
+}
+
+browser.runtime.onMessage.addListener((m) => {
+ if (!m || m.t !== 'fg-watched') return;
+ if (!m.code || !/^[A-Za-z0-9_.-]{3,}$/.test(m.code)) return;
+
+ const payload = {
+ type: m.type === 'meta' ? 'meta' : 'watched',
+ code: m.code,
+ author: String(m.author || ''),
+ caption: String(m.caption || ''),
+ };
+
+ try {
+ const port = getPort();
+ if (port) {
+ port.postMessage(payload);
+ }
+ } catch (_) {}
+});
diff --git a/Ported/app/src/main/assets/reelhistory/manifest.json b/Ported/app/src/main/assets/reelhistory/manifest.json
new file mode 100644
index 0000000..e210052
--- /dev/null
+++ b/Ported/app/src/main/assets/reelhistory/manifest.json
@@ -0,0 +1,13 @@
+{
+ "manifest_version": 2,
+ "name": "FocusGram Reel History",
+ "version": "1.0",
+ "browser_specific_settings": { "gecko": { "id": "reelhistory@focusgram.app" } },
+ "permissions": ["nativeMessaging", "*://*.instagram.com/*"],
+ "background": { "scripts": ["bg.js"] },
+ "content_scripts": [{
+ "matches": ["*://*.instagram.com/*"],
+ "js": ["reels.js"],
+ "run_at": "document_start"
+ }]
+}
diff --git a/Ported/app/src/main/assets/reelhistory/reels.js b/Ported/app/src/main/assets/reelhistory/reels.js
new file mode 100644
index 0000000..4512142
--- /dev/null
+++ b/Ported/app/src/main/assets/reelhistory/reels.js
@@ -0,0 +1,346 @@
+const DWELL_MS = 1000; // a reel counts after 1 second on screen
+const REEL_RE = /^\/reels?\/([A-Za-z0-9_-]{8,})\/?/;
+const CODE_RE = /^[A-Za-z0-9_.-]{3,}$/;
+
+// ---- In-Page API Interception via Injected Script ----
+// Content scripts run in an isolated world where exportFunction may not exist.
+// Injecting a DOM script into the page context guarantees direct access to fetch and XMLHttpRequest.
+(function injectApiHook() {
+ const SCRIPT_ID = 'fg-reel-api-hook';
+ if (document.getElementById(SCRIPT_ID)) return;
+ const s = document.createElement('script');
+ s.id = SCRIPT_ID;
+ s.textContent = `
+ (function() {
+ const CODE_RE = /^[A-Za-z0-9_-]{8,}$/;
+ window.__fg_media_cache = window.__fg_media_cache || new Map();
+
+ function harvest(obj, depth) {
+ if (!obj || typeof obj !== 'object' || depth > 14) return;
+ if (Array.isArray(obj)) { for (const x of obj) harvest(x, depth + 1); return; }
+ if (typeof obj.code === 'string' && CODE_RE.test(obj.code)) {
+ const cap = obj.caption && typeof obj.caption.text === 'string' ? obj.caption.text : '';
+ const author = obj.user && typeof obj.user.username === 'string' ? obj.user.username : '';
+
+ let videoUrl = '';
+ if (Array.isArray(obj.video_versions) && obj.video_versions.length > 0) {
+ videoUrl = obj.video_versions[0].url || '';
+ }
+ let imageUrl = '';
+ if (obj.image_versions2 && Array.isArray(obj.image_versions2.candidates) && obj.image_versions2.candidates.length > 0) {
+ imageUrl = obj.image_versions2.candidates[0].url || '';
+ }
+
+ if (videoUrl || imageUrl) {
+ window.__fg_media_cache.set(obj.code, {
+ code: obj.code,
+ videoUrl: videoUrl,
+ imageUrl: imageUrl,
+ isVideo: !!videoUrl,
+ author: author,
+ caption: cap.slice(0, 300)
+ });
+ while (window.__fg_media_cache.size > 200) {
+ window.__fg_media_cache.delete(window.__fg_media_cache.keys().next().value);
+ }
+ }
+
+ if (author) {
+ window.dispatchEvent(new CustomEvent('fg-api-reel', {
+ detail: { code: obj.code, author: author, caption: cap.slice(0, 300) }
+ }));
+ }
+ }
+ for (const k in obj) harvest(obj[k], depth + 1);
+ }
+
+ function remember(text) {
+ try { harvest(JSON.parse(text), 0); } catch (_) {}
+ }
+
+ window.__fg_fetch_post_media = async function(shortcode) {
+ if (!shortcode) return null;
+ if (window.__fg_media_cache && window.__fg_media_cache.has(shortcode)) {
+ return window.__fg_media_cache.get(shortcode);
+ }
+ try {
+ const apiURL = new URL('/graphql/query', window.location.origin);
+ const params = new URLSearchParams({
+ fb_api_caller_class: 'RelayModern',
+ fb_api_req_friendly_name: 'PolarisPostRootQuery',
+ doc_id: '27852811784380813',
+ variables: JSON.stringify({
+ shortcode: shortcode,
+ __relay_internal__pv__PolarisShortDramaEnabledrelayprovider: false,
+ __relay_internal__pv__PolarisMultiCaptionCarouselEnabledrelayprovider: false,
+ }),
+ server_timestamps: 'true',
+ });
+ const res = await window.fetch(apiURL.href, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'x-fb-friendly-name': 'PolarisPostRootQuery',
+ 'x-ig-app-id': '936619743392459',
+ 'x-requested-with': 'XMLHttpRequest',
+ },
+ body: params.toString(),
+ credentials: 'include'
+ });
+ if (res.ok) {
+ const data = await res.json();
+ const item = data?.data?.xdt_api__v1__media__shortcode__web_info?.items?.[0];
+ if (item) {
+ let videoUrl = (item.video_versions && item.video_versions[0]?.url) || '';
+ let imageUrl = (item.image_versions2?.candidates && item.image_versions2.candidates[0]?.url) || '';
+ const result = {
+ code: shortcode,
+ videoUrl: videoUrl,
+ imageUrl: imageUrl,
+ isVideo: !!videoUrl,
+ author: item.user?.username || '',
+ caption: item.caption?.text || ''
+ };
+ window.__fg_media_cache.set(shortcode, result);
+ return result;
+ }
+ }
+ } catch (_) {}
+ return null;
+ };
+
+ window.addEventListener('fg-request-media', async (e) => {
+ const code = e?.detail?.code;
+ const reqId = e?.detail?.reqId;
+ if (!code || !reqId) return;
+ let media = window.__fg_media_cache && window.__fg_media_cache.get(code);
+ if (!media && typeof window.__fg_fetch_post_media === 'function') {
+ media = await window.__fg_fetch_post_media(code);
+ }
+ window.dispatchEvent(new CustomEvent('fg-response-media-' + reqId, {
+ detail: media || null
+ }));
+ });
+
+ const isApi = (u) => typeof u === 'string' && (u.includes('graphql') || u.includes('/api/v1/') || u.includes('/clips/'));
+
+ const origFetch = window.fetch;
+ if (origFetch) {
+ window.fetch = async function(input, init) {
+ const p = origFetch.apply(this, arguments);
+ try {
+ const url = typeof input === 'string' ? input : (input && input.url) || '';
+ if (isApi(url)) {
+ p.then(res => {
+ try { res.clone().text().then(remember).catch(() => {}); } catch (_) {}
+ }).catch(() => {});
+ }
+ } catch (_) {}
+ return p;
+ };
+ }
+
+ const XP = window.XMLHttpRequest && window.XMLHttpRequest.prototype;
+ if (XP) {
+ const origOpen = XP.open;
+ const origSend = XP.send;
+ XP.open = function(method, url) {
+ this.__fg_url = String(url);
+ return origOpen.apply(this, arguments);
+ };
+ XP.send = function() {
+ try {
+ if (this.__fg_url && isApi(this.__fg_url)) {
+ this.addEventListener('load', function() {
+ try {
+ if (!this.responseType || this.responseType === 'text') remember(this.responseText);
+ } catch (_) {}
+ });
+ }
+ } catch (_) {}
+ return origSend.apply(this, arguments);
+ };
+ }
+ })();
+ `;
+ (document.head || document.documentElement).appendChild(s);
+})();
+
+// ---- Source 2: Store harvested API metadata ----
+const apiMeta = new Map(); // code -> { author, caption }
+const authorToCode = new Map(); // lowercase username -> code
+
+window.addEventListener('fg-api-reel', (e) => {
+ if (e && e.detail) {
+ const d = e.detail;
+ apiMeta.set(d.code, { author: d.author, caption: d.caption });
+ if (d.author) authorToCode.set(d.author.toLowerCase(), d.code);
+ while (apiMeta.size > 400) apiMeta.delete(apiMeta.keys().next().value);
+ }
+});
+
+// ---- Source 1 (primary): the tags in the page head ----
+function fromHead(code) {
+ const get = (sel, attr) => {
+ const e = document.querySelector(sel);
+ return e ? (e.getAttribute(attr) || '') : '';
+ };
+ const canon = get('link[rel="canonical"]', 'href');
+ const ogUrl = get('meta[property="og:url"]', 'content');
+ const alUrl = get('meta[property="al:android:url"]', 'content');
+ if (![canon, ogUrl, alUrl].some((u) => u.includes(code))) return null;
+
+ let author = '';
+ let m = new RegExp('instagram\\.com/([A-Za-z0-9._]+)/reels?/' + code).exec(ogUrl);
+ if (m) author = m[1];
+ if (!author) {
+ m = /\(@([A-Za-z0-9._]+)\)/.exec(get('meta[name="twitter:title"]', 'content'));
+ if (m) author = m[1];
+ }
+ m = /^[\s\S]*? on Instagram: "([\s\S]*)"\s*$/.exec(get('meta[property="og:title"]', 'content'));
+ const caption = m ? m[1].slice(0, 300) : '';
+ return author || caption ? { author, caption } : null;
+}
+
+// Head first, DOM second, API third, field by field.
+function lookup(code) {
+ const a = apiMeta.get(code) || {};
+ const h = fromHead(code) || {};
+ let author = a.author || h.author || '';
+ let caption = a.caption || h.caption || '';
+
+ if (!author || !caption) {
+ const videos = Array.from(document.querySelectorAll('video'));
+ for (const v of videos) {
+ let card = v.closest('article') || v.closest('section') || v.parentElement;
+ while (card && card !== document.body && card !== document.documentElement) {
+ if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
+ const r = card.getBoundingClientRect();
+ if (r.height > window.innerHeight * 0.5) break;
+ card = card.parentElement;
+ }
+ if (!card) continue;
+ const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]');
+ if (link && link.href && link.href.includes(code)) {
+ if (!author) {
+ const aEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"])');
+ if (aEl) author = aEl.textContent.trim().replace(/^@/, '');
+ }
+ if (!caption) {
+ const textCandidates = Array.from(card.querySelectorAll('h1, h2, span[dir="auto"], div[dir="auto"]'));
+ for (const el of textCandidates) {
+ if (el.closest('a[href*="/audio/"]') ||
+ el.closest('header') ||
+ el.closest('button') ||
+ el.closest('[role="button"]') ||
+ el.closest('a[role="link"]')) {
+ continue;
+ }
+ const txt = (el.textContent || '').trim();
+ const lower = txt.toLowerCase();
+ if (txt.length >= 2 &&
+ txt !== author &&
+ !lower.includes('follow') &&
+ !lower.startsWith('liked by') &&
+ !/^\d+[\d,.]*[KkMm]?$/.test(txt) &&
+ !lower.includes('original audio') &&
+ !['ad', 'sponsored'].includes(lower)) {
+ caption = txt.slice(0, 300);
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+ }
+
+ if (!author && code.startsWith('reel_')) {
+ author = code.replace(/^reel_/, '');
+ }
+ return { author: author || '', caption: caption || '' };
+}
+
+// ---- Detect which reel is on screen and report it ----
+let cur = null;
+let timer = null;
+const unfilled = new Set();
+
+function report(type, code) {
+ const m = lookup(code);
+ browser.runtime.sendMessage({
+ t: 'fg-watched', type, code, author: m.author, caption: m.caption,
+ }).catch(() => {});
+ return m;
+}
+
+function getActiveReelCode() {
+ const m = REEL_RE.exec(location.pathname);
+ if (m) return m[1];
+
+ const videos = Array.from(document.querySelectorAll('video'));
+ const activeVideo = videos.find(v => !v.paused && v.getBoundingClientRect().height > 200) ||
+ videos.find(v => {
+ const r = v.getBoundingClientRect();
+ return r.top >= -50 && r.top < window.innerHeight * 0.6;
+ }) || videos[0];
+ if (!activeVideo) return null;
+
+ let card = activeVideo.closest('article') || activeVideo.closest('section') || activeVideo.parentElement;
+ while (card && card !== document.body && card !== document.documentElement) {
+ if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
+ const r = card.getBoundingClientRect();
+ if (r.height > window.innerHeight * 0.5) break;
+ card = card.parentElement;
+ }
+ if (!card) return null;
+
+ // 1. Direct link to reel
+ const link = card.querySelector('a[href*="/reel/"], a[href*="/p/"], a[href*="/reels/"]');
+ if (link && link.href) {
+ const lm = /\/(?:reel|reels|p)\/([A-Za-z0-9_-]{8,})/.exec(link.href);
+ if (lm) return lm[1];
+ }
+
+ // 2. Identify author
+ const authorEl = card.querySelector('header a[role="link"], a[role="link"][href^="/"]:not([href*="/audio/"]):not([href*="/reel"]), a[href^="/"]:not([href*="/audio/"]):not([href*="/reel"])');
+ if (authorEl) {
+ let username = authorEl.textContent.trim().replace(/^@/, '').toLowerCase();
+ if (!username && authorEl.getAttribute('href')) {
+ const match = authorEl.getAttribute('href').match(/^\/([A-Za-z0-9._]+)\/?/);
+ if (match) username = match[1].toLowerCase();
+ }
+ if (username) {
+ return 'reel_' + username;
+ }
+ }
+
+ return null;
+}
+
+function tick() {
+ const code = getActiveReelCode();
+ if (code === cur) return;
+ cur = code;
+ clearTimeout(timer);
+ if (!code) return;
+ timer = setTimeout(() => {
+ if (cur !== code || document.visibilityState !== 'visible') return;
+ const found = report('watched', code);
+ if (!found.author || !found.caption) unfilled.add(code);
+ }, DWELL_MS);
+}
+
+function fill() {
+ unfilled.forEach((code) => {
+ const m = lookup(code);
+ if (m.author || m.caption) {
+ report('meta', code);
+ if (m.author && m.caption) unfilled.delete(code);
+ }
+ });
+ if (unfilled.size > 50) unfilled.delete(unfilled.values().next().value);
+}
+
+setInterval(tick, 300);
+setInterval(fill, 1000);
diff --git a/Ported/app/src/main/assets/scripts/content_hider.js b/Ported/app/src/main/assets/scripts/content_hider.js
index 4d897cd..2a69fb1 100644
--- a/Ported/app/src/main/assets/scripts/content_hider.js
+++ b/Ported/app/src/main/assets/scripts/content_hider.js
@@ -249,16 +249,53 @@
`;
}
+ /* Instagram Reels Layout & Overlay Lift (Always active, regardless of ad blocker) */
+ css += `
+ :root {
+ --fg-reel-bottom-offset: 16px;
+ --fg-reel-scrubber-offset: 2px;
+ }
+
+ [data-fg-reel-scroller],
+ div[style*="position: fixed"][style*="overflow-y: scroll"],
+ div[style*="position: fixed"][style*="overflow-y: auto"],
+ div[style*="position: fixed"][style*="overflow: scroll"] {
+ bottom: 0px !important;
+ height: 100% !important;
+ max-height: none !important;
+ }
+
+ [data-fg-reel-overlay] {
+ bottom: var(--fg-reel-bottom-offset, 88px) !important;
+ margin-bottom: 0px !important;
+ z-index: 25 !important;
+ pointer-events: auto !important;
+ }
+
+ [data-fg-reel-actions] {
+ bottom: var(--fg-reel-bottom-offset, 88px) !important;
+ z-index: 25 !important;
+ pointer-events: auto !important;
+ }
+
+ [data-fg-reel-scrubber] {
+ bottom: var(--fg-reel-scrubber-offset, 76px) !important;
+ z-index: 26 !important;
+ }
+ `;
+
if (config.noAds !== false) {
css += `
article:has(a[href*="/ad_"]),
article:has(a[href*="/ads/"]),
- article:has(a[href*="/about/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"] {
- height: 1px !important;
- min-height: 1px !important;
- max-height: 1px !important;
+ display: none !important;
+ height: 0px !important;
+ min-height: 0px !important;
+ max-height: 0px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
@@ -266,9 +303,14 @@
padding: 0 !important;
border: none !important;
}
+ div[data-fg-reel-ad="1"] {
+ visibility: hidden !important;
+ pointer-events: none !important;
+ }
div[data-ad-preview],
div[data-testid="post_ad"],
- div[data-testid="suggested-users"] {
+ div[data-testid="suggested-users"],
+ div[aria-label="Ad" i]:not(main):not([role="feed"]) {
display: none !important;
}
`;
@@ -291,23 +333,36 @@
try {
// Filter Sponsored Ads
if (config.noAds !== false) {
- const SPONSORED_KW = ['sponsored', 'gesponsert', 'sponsorisé', 'publicidad', 'patrocinado', 'sponsorizzato', 'paid partnership'];
- const AD_CTA = ['learn more', 'shop now', 'install now', 'sign up', 'apply now', 'book now', 'download', 'order now', 'get offer', 'subscribe', 'contact us'];
+ const SPONSORED_KW = ['sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung', 'anuncio', 'werbeanzeige'];
+ const AD_CTA = ['shop now', 'install now', 'sign up', 'apply now', 'book now', 'order now', 'get offer'];
// Strictly target individual article elements to avoid hiding feed wrappers
document.querySelectorAll('article').forEach((article) => {
if (article.dataset.fgAdFiltered) return;
let isAd = false;
- const spans = article.querySelectorAll('header span, header a, div[role="button"] span, a[role="button"]');
- for (let i = 0; i < spans.length; i++) {
- const t = (spans[i].textContent || '').trim().toLowerCase();
- if (SPONSORED_KW.some(kw => t === kw || t.startsWith(kw)) || AD_CTA.some(cta => t === cta)) {
+ const headerElements = article.querySelectorAll('header span, header [aria-label*="Sponsored" i], header [aria-label="Ad" i]');
+ for (let i = 0; i < headerElements.length; i++) {
+ if (headerElements[i].children.length > 2) continue;
+ const t = (headerElements[i].textContent || '').trim().toLowerCase();
+ if (!t || t.length > 30) continue;
+ const clean = t.replace(/[^a-z]/g, '');
+ if (clean === 'ad' || SPONSORED_KW.some(kw => t === kw || clean === kw)) {
isAd = true;
break;
}
}
- if (!isAd && article.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="/about/ads"], a[href*="ads.instagram.com"]')) {
+ if (!isAd && article.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="ads.instagram.com"]')) {
isAd = true;
}
+ if (!isAd) {
+ const ctaButtons = article.querySelectorAll('div[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), a[role="button"]:not([data-fg-x-act]):not([data-fg-download-btn]):not(.fg-x-act-btn), button');
+ for (let b = 0; b < ctaButtons.length; b++) {
+ const bText = (ctaButtons[b].textContent || '').trim().toLowerCase();
+ if (AD_CTA.some(cta => bText === cta)) {
+ isAd = true;
+ break;
+ }
+ }
+ }
if (isAd) {
article.style.setProperty('height', '1px', 'important');
article.style.setProperty('min-height', '1px', 'important');
@@ -322,37 +377,46 @@
}
});
- // Filter Reels video ads on reels and feed
+ // Filter Reels video ads on reels surface without ever breaking layout
document.querySelectorAll('video').forEach((video) => {
- let container = video.closest('article') || video.closest('section') || video.closest('[role="region"]') || video.parentElement;
- while (container && container !== document.body && container !== document.documentElement) {
- if (container.tagName === 'ARTICLE' || container.tagName === 'SECTION' || container.parentElement?.tagName === 'MAIN') break;
- const r = container.getBoundingClientRect();
- if (r.height > window.innerHeight * 0.45) break;
- container = container.parentElement;
+ const card = video.closest('[data-fg-reel-card="1"]') || video.closest('article');
+ if (!card || card.dataset.fgAdFiltered || card.dataset.fgReelAd) return;
+
+ let isSponsored = !!card.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="ads.instagram.com"], a[href*="/about/ads"]') ||
+ !!card.querySelector('[aria-label*="Sponsored" i], [aria-label="Ad" i], [data-ad-preview]');
+
+ if (!isSponsored) {
+ const badges = card.querySelectorAll('span, div[role="button"], button');
+ for (let s = 0; s < badges.length; s++) {
+ const el = badges[s];
+ if (el.children.length > 2) continue;
+ const raw = (el.textContent || '').trim();
+ if (!raw || raw.length > 15) continue;
+ if (/^[\u{1F300}-\u{1F9FF}\s]*ad$/iu.test(raw) || /^sponsored$/i.test(raw) || /^paid\s+partnership$/i.test(raw)) {
+ if (!el.closest('span[dir="auto"]')) {
+ isSponsored = true;
+ break;
+ }
+ }
+ }
}
- if (!container || container.dataset.fgAdFiltered) return;
- const t = (container.textContent || '').trim().toLowerCase();
- const isSponsored = SPONSORED_KW.some(kw => t.includes(kw)) ||
- !!container.querySelector('a[href*="/ad_"], a[href*="/ads/"], a[href*="/about/ads"], a[href*="ads.instagram.com"]') ||
- !!container.querySelector('[aria-label*="Sponsored" i], [aria-label*="sponsored" i], [data-ad-preview]');
-
- const hasAdCTA = !!container.querySelector('div[role="button"], a[role="button"]') &&
- AD_CTA.some(cta => t.includes(cta));
-
- if (isSponsored || hasAdCTA) {
+ if (isSponsored) {
video.muted = true;
try { video.pause(); } catch (_) {}
- container.style.setProperty('display', 'none', 'important');
- container.style.setProperty('height', '0px', 'important');
- container.style.setProperty('opacity', '0', 'important');
- container.style.setProperty('pointer-events', 'none', 'important');
- container.dataset.fgAdFiltered = 'true';
+ card.setAttribute('data-fg-reel-ad', '1');
+ card.dataset.fgAdFiltered = 'true';
+ card.style.setProperty('visibility', 'hidden', 'important');
+ card.style.setProperty('pointer-events', 'none', 'important');
- const r = container.getBoundingClientRect();
+ const r = card.getBoundingClientRect();
if (r.top >= -100 && r.top <= window.innerHeight * 0.8) {
- window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40, which: 40, bubbles: true }));
+ const scroller = card.closest('[data-fg-reel-scroller]') || document.querySelector('[data-fg-reel-scroller="1"]');
+ if (scroller && scroller !== window) {
+ try { scroller.scrollBy({ top: scroller.clientHeight || window.innerHeight, behavior: 'instant' }); } catch (_) {}
+ } else {
+ try { window.scrollBy({ top: window.innerHeight, behavior: 'instant' }); } catch (_) {}
+ }
}
}
});
@@ -363,7 +427,7 @@
// Strictly target individual article elements or specific suggested-users widgets
document.querySelectorAll('article').forEach((article) => {
if (article.dataset.fgSuggestedHidden) return;
- const headers = article.querySelectorAll('header span, header h2, header h3, h2, h3, h4, span');
+ const headers = article.querySelectorAll('header span, header h2, header h3, h2, h3, h4');
for (let i = 0; i < headers.length; i++) {
const text = (headers[i].textContent || '').trim().toLowerCase();
if (text === 'suggested for you' || text === 'suggested posts' || text === 'suggested post' || text === 'suggestions for you') {
@@ -435,6 +499,9 @@
});
}
+ // Always tag and lift Reels bottom metadata & action buttons above the floating nav bar
+ tagReelsOverlay();
+
// Minimal Mode in-page SPA redirect guard
if (config.minimalMode) {
const curPath = (window.location.pathname || '').toLowerCase();
@@ -445,6 +512,110 @@
} catch (_) {}
}
+ function tagReelsOverlay() {
+ const path = (window.location.pathname || '').toLowerCase();
+ if (!path.startsWith('/reels') && !path.startsWith('/reel')) return;
+ const vh = window.innerHeight;
+ if (!vh) return;
+
+ const firstVideo = document.querySelector('video');
+ if (firstVideo) {
+ let el = firstVideo.parentElement;
+ while (el && el !== document.body && el !== document.documentElement) {
+ const cs = window.getComputedStyle(el);
+ if (cs.position === 'fixed' || cs.overflowY === 'scroll' || cs.overflowY === 'auto') {
+ if (!el.hasAttribute('data-fg-reel-scroller')) {
+ el.setAttribute('data-fg-reel-scroller', '1');
+ }
+ el.style.setProperty('bottom', '0px', 'important');
+ el.style.setProperty('height', '100%', 'important');
+ el.style.setProperty('max-height', 'none', 'important');
+ break;
+ }
+ el = el.parentElement;
+ }
+ }
+
+ document.querySelectorAll('video').forEach(v => {
+ let card = v.closest('article') || v.closest('section') || v.closest('[role="region"]') || v.parentElement;
+ while (card && card !== document.body && card !== document.documentElement) {
+ if (card.tagName === 'ARTICLE' || card.tagName === 'SECTION') break;
+ if (card.getBoundingClientRect().height > vh * 0.5) break;
+ card = card.parentElement;
+ }
+ if (!card || card.tagName === 'MAIN' || card.id === 'react-root' || card.querySelectorAll('video').length > 1) return;
+
+ let overlay = null;
+ const audioLink = card.querySelector('a[href*="/audio/"]');
+ if (audioLink) {
+ let p = audioLink.parentElement;
+ while (p && p !== card && p !== document.body) {
+ const hasAuthorOrCaption = p.querySelector('a[role="link"]:not([href*="/audio/"])') || p.querySelector('span[dir="auto"]');
+ if (hasAuthorOrCaption) {
+ overlay = p;
+ let candidate = p;
+ while (candidate && candidate.parentElement && candidate.parentElement !== card) {
+ const cs = window.getComputedStyle(candidate);
+ if (cs.position === 'absolute' || cs.position === 'fixed') {
+ overlay = candidate;
+ break;
+ }
+ candidate = candidate.parentElement;
+ }
+ break;
+ }
+ p = p.parentElement;
+ }
+ if (!overlay) overlay = audioLink.closest('div[style*="bottom"]') || audioLink.parentElement;
+ }
+
+ if (!overlay) {
+ const captions = card.querySelectorAll('span[dir="auto"], div[dir="auto"]');
+ for (let i = 0; i < captions.length; i++) {
+ const c = captions[i];
+ if (c.textContent && c.textContent.trim().length > 0) {
+ let p = c.closest('div');
+ while (p && p !== card && p !== document.body) {
+ const cs = window.getComputedStyle(p);
+ if (cs.position === 'absolute' || cs.position === 'fixed' || p.parentElement === card) {
+ overlay = p;
+ break;
+ }
+ p = p.parentElement;
+ }
+ if (overlay) break;
+ }
+ }
+ }
+
+ if (overlay && overlay !== card) {
+ overlay.setAttribute('data-fg-reel-overlay', '1');
+ overlay.style.setProperty('bottom', 'var(--fg-reel-bottom-offset, 16px)', 'important');
+ overlay.style.removeProperty('padding-bottom');
+ overlay.style.setProperty('margin-bottom', '0px', 'important');
+ overlay.style.setProperty('z-index', '25', 'important');
+ overlay.style.setProperty('pointer-events', 'auto', 'important');
+ }
+
+ const likeIcon = card.querySelector('svg[aria-label="Like"], svg[aria-label="Unlike"], svg[aria-label="Liked"]');
+ if (likeIcon) {
+ let actionRail = likeIcon.closest('div[role="button"]') || likeIcon.parentElement;
+ while (actionRail && actionRail !== card && actionRail !== document.body) {
+ const cs = window.getComputedStyle(actionRail);
+ if (cs.position === 'absolute' || cs.position === 'fixed' || (actionRail.parentElement === card)) break;
+ if (actionRail.parentElement && (actionRail.parentElement.querySelector('a[href*="/audio/"]') || actionRail.parentElement.querySelector('video'))) break;
+ actionRail = actionRail.parentElement;
+ }
+ if (actionRail && actionRail !== card) {
+ actionRail.setAttribute('data-fg-reel-actions', '1');
+ actionRail.style.setProperty('bottom', 'var(--fg-reel-bottom-offset, 16px)', 'important');
+ actionRail.style.setProperty('z-index', '25', 'important');
+ actionRail.style.setProperty('pointer-events', 'auto', 'important');
+ }
+ }
+ });
+ }
+
// --- Feed Path Monitoring & Unblur Interaction Engine ---
function updateFeedAttr() {
const path = window.location.pathname || '';
diff --git a/Ported/app/src/main/assets/scripts/fetch_interceptor.js b/Ported/app/src/main/assets/scripts/fetch_interceptor.js
new file mode 100644
index 0000000..b39db48
--- /dev/null
+++ b/Ported/app/src/main/assets/scripts/fetch_interceptor.js
@@ -0,0 +1,230 @@
+/**
+ * FocusGram Unified Network Ad & Content Filter via Main-World Fetch Interception
+ * Injected at document_start in the page execution context.
+ *
+ * Intercepts Instagram GraphQL and REST API responses (Feed, Reels, Stories)
+ * and purges ad/sponsored objects before React receives them.
+ * This guarantees:
+ * 1. Ads are 100% blocked before rendering.
+ * 2. React never builds DOM elements for ads.
+ * 3. Scroll-snap and virtual scroller remain continuous and never collapse or go blank.
+ */
+'use strict';
+
+(function () {
+ if (window.__fgFetchInterceptorInstalled) return;
+ window.__fgFetchInterceptorInstalled = true;
+
+ window.__fgFilterConfig = {
+ blockAds: true,
+ blockSponsored: true,
+ blockSuggested: false,
+ blockVideos: false,
+ blockAutoplay: false,
+ blockGraphQLQueryWhenFeedPosts: false
+ };
+
+ const AD_KEYWORDS = [
+ 'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad',
+ 'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung',
+ 'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags'
+ ];
+
+ function textHasAdSignal(value) {
+ if (!value) return false;
+ const s = String(value).toLowerCase();
+ return AD_KEYWORDS.some(kw => s.includes(kw));
+ }
+
+ 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
+ });
+
+ 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)
+ );
+ }
+
+ 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 || ''))
+ );
+ }
+
+ 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')
+ );
+ }
+
+ function shouldFilterNode(node) {
+ const config = window.__fgFilterConfig;
+ if (!config || !node || typeof node !== 'object') return false;
+
+ // Filter ads
+ if (config.blockAds !== false && isAdNode(node)) {
+ return true;
+ }
+
+ // Filter sponsored posts
+ if (config.blockSponsored !== false && isSponsoredNode(node) && !isAdNode(node)) {
+ return true;
+ }
+
+ // Filter suggested posts if enabled
+ if (config.blockSuggested && isSuggestedNode(node)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ function unwrapNode(item) {
+ if (!item || typeof item !== 'object') return item;
+ return item.media || item.node || item.post || item;
+ }
+
+ function filterList(list) {
+ if (!Array.isArray(list)) return list;
+ return list.filter(item => {
+ if (!item) return false;
+ const target = unwrapNode(item);
+ return !shouldFilterNode(target);
+ });
+ }
+
+ function walkAndFilter(obj, visited = new Set()) {
+ if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
+ visited.add(obj);
+
+ if (Array.isArray(obj)) {
+ for (let i = 0; i < obj.length; i++) {
+ walkAndFilter(obj[i], visited);
+ }
+ return;
+ }
+
+ if (obj.edges && Array.isArray(obj.edges)) {
+ obj.edges = filterList(obj.edges);
+ }
+ if (obj.items && Array.isArray(obj.items)) {
+ obj.items = filterList(obj.items);
+ }
+ if (obj.feed_items && Array.isArray(obj.feed_items)) {
+ obj.feed_items = filterList(obj.feed_items);
+ }
+ if (obj.tray && Array.isArray(obj.tray)) {
+ obj.tray = filterList(obj.tray);
+ }
+
+ for (const key in obj) {
+ if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') {
+ const val = obj[key];
+ if (val && typeof val === 'object') {
+ walkAndFilter(val, visited);
+ }
+ }
+ }
+ }
+
+ // Override fetch in the main world
+ const _fetch = window.fetch.bind(window);
+
+ window.fetch = async function (input, init) {
+ const url = typeof input === 'string'
+ ? input
+ : input instanceof URL
+ ? input.href
+ : input?.url ?? '';
+
+ const response = await _fetch(input, init);
+
+ // Only inspect GraphQL, feed, and clips endpoints
+ if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/')) {
+ return response;
+ }
+
+ const config = window.__fgFilterConfig;
+ if (config && config.blockAds === false) {
+ return response;
+ }
+
+ const cloned = response.clone();
+ try {
+ const contentType = response.headers.get('content-type') || '';
+ if (!contentType.includes('application/json')) {
+ return response;
+ }
+
+ const data = await cloned.json();
+ walkAndFilter(data);
+
+ return new Response(JSON.stringify(data), {
+ status: response.status,
+ statusText: response.statusText,
+ headers: response.headers
+ });
+ } catch (_) {
+ return response;
+ }
+ };
+
+ Object.defineProperty(window, 'fetch', {
+ value: window.fetch,
+ writable: true,
+ configurable: true
+ });
+
+ window.__fgSetFilterConfig = function (cfg) {
+ if (typeof cfg === 'object' && cfg) {
+ Object.assign(window.__fgFilterConfig, cfg);
+ }
+ };
+})();
diff --git a/Ported/app/src/main/assets/scripts/native_feel.js b/Ported/app/src/main/assets/scripts/native_feel.js
index 8ecaa13..34e8b8b 100644
--- a/Ported/app/src/main/assets/scripts/native_feel.js
+++ b/Ported/app/src/main/assets/scripts/native_feel.js
@@ -45,8 +45,8 @@
user-select: text !important;
}
- /* 4. Enforce hardware-accelerated momentum touch scrolling */
- * {
+ /* 4. Enforce hardware-accelerated momentum touch scrolling on scrollable containers */
+ html, body, [data-fg-reel-scroller], div[style*="overflow"], main {
-webkit-overflow-scrolling: touch !important;
}
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 e49e3b1..a816e22 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt
@@ -1,33 +1,56 @@
package com.ujwal.focusgram
+import android.os.Build
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.systemBarsPadding
-import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.Surface
-import androidx.compose.material3.Text
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
import com.ujwal.focusgram.core.ui.theme.FocusGramTheme
-
import com.ujwal.focusgram.ui.FocusGramApp
/**
* MainActivity
* Single-Activity host for Jetpack Compose UI and GeckoView container.
+ * Requests native 120Hz/high display refresh rate for fluid scrolling.
*/
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
+ applyHighRefreshRate()
setContent {
FocusGramTheme {
FocusGramApp()
}
}
}
+
+ private fun applyHighRefreshRate() {
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ val display = display
+ val modes = display?.supportedModes ?: emptyArray()
+ val maxMode = modes.maxByOrNull { it.refreshRate }
+ if (maxMode != null) {
+ val params = window.attributes
+ params.preferredDisplayModeId = maxMode.modeId
+ params.preferredRefreshRate = maxMode.refreshRate
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ params.setFrameRateBoostOnTouchEnabled(true)
+ }
+ window.attributes = params
+ }
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ @Suppress("DEPRECATION")
+ val modes = window.windowManager.defaultDisplay.supportedModes
+ val maxMode = modes.maxByOrNull { it.refreshRate }
+ if (maxMode != null) {
+ val params = window.attributes
+ params.preferredDisplayModeId = maxMode.modeId
+ params.preferredRefreshRate = maxMode.refreshRate
+ window.attributes = params
+ }
+ }
+ } catch (_: Exception) {}
+ }
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt
index f3010fc..987d20f 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Daos.kt
@@ -33,6 +33,30 @@ interface ScreenTimeDao {
}
}
+ @Query("UPDATE screen_time_records SET reelsWatched = reelsWatched + :delta, lastUpdated = :timestamp WHERE date = :date")
+ suspend fun incrementReelsWatched(date: String, delta: Int = 1, timestamp: Long = System.currentTimeMillis())
+
+ @Query("UPDATE screen_time_records SET reelsWatched = :count, lastUpdated = :timestamp WHERE date = :date")
+ suspend fun setReelsWatched(date: String, count: Int, timestamp: Long = System.currentTimeMillis())
+
+ suspend fun recordReelWatched(date: String) {
+ val existing = getForDateSync(date)
+ if (existing == null) {
+ insertOrUpdate(ScreenTimeRecord(date = date, seconds = 0, reelsSeconds = 0, reelsWatched = 1))
+ } else {
+ incrementReelsWatched(date = date, delta = 1)
+ }
+ }
+
+ suspend fun syncReelsWatched(date: String, count: Int) {
+ val existing = getForDateSync(date)
+ if (existing == null) {
+ insertOrUpdate(ScreenTimeRecord(date = date, seconds = 0, reelsSeconds = 0, reelsWatched = count))
+ } else {
+ setReelsWatched(date = date, count = count)
+ }
+ }
+
@Query("DELETE FROM screen_time_records WHERE date < :cutoffDate")
suspend fun pruneOlderThan(cutoffDate: String)
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt
index b99ac69..bc6fe8d 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/Entities.kt
@@ -13,6 +13,7 @@ data class ScreenTimeRecord(
val date: String, // Format: YYYY-MM-DD
val seconds: Int,
val reelsSeconds: Int = 0,
+ val reelsWatched: Int = 0,
val lastUpdated: Long = System.currentTimeMillis()
)
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt
index fa994d1..559bb78 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/database/FocusGramDatabase.kt
@@ -17,7 +17,7 @@ import androidx.room.RoomDatabase
SessionRecord::class,
SnapshotItem::class
],
- version = 2,
+ version = 3,
exportSchema = false
)
abstract class FocusGramDatabase : RoomDatabase() {
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 c4c9501..48e8c7c 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,7 +74,7 @@ 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] ?: false,
+ videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: true,
sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true,
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
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 cc5c102..e01588e 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
@@ -32,6 +32,8 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
var onModalOrChatActive: ((Boolean) -> Unit)? = null
var onThemeChanged: ((String) -> Unit)? = null
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
/**
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
@@ -45,6 +47,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
.await()
if (extension != null) {
+ try {
+ runtime.webExtensionController
+ .enable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
+ .await()
+ } catch (_: Exception) {}
contentBlockerExtension = extension
_isContentBlockerActive.value = true
setupMessageDelegate(extension)
@@ -136,19 +143,48 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
Log.d(TAG, "Received themeChanged from Instagram web: $theme")
mainHandler.post { onThemeChanged?.invoke(theme) }
}
- "reelViewed" -> {
+ "reelViewed", "reelWatched" -> {
val reelObj = json.optJSONObject("reel")
- val url = reelObj?.optString("url")?.takeIf { it.isNotBlank() } ?: json.optString("url", "")
- val title = reelObj?.optString("title")?.takeIf { it.isNotBlank() } ?: json.optString("title", "Instagram Reel")
- val author = reelObj?.optString("author")?.takeIf { it.isNotBlank() } ?: json.optString("author", "Instagram Creator")
+ var code = json.optString("code", "").trim()
+ if (code.isBlank() && reelObj != null) {
+ code = reelObj.optString("code", "").trim()
+ }
+ var url = reelObj?.optString("url")?.takeIf { it.isNotBlank() } ?: json.optString("url", "")
+ val rawCaption = reelObj?.optString("caption")?.takeIf { it.isNotBlank() } ?: json.optString("caption", "")
+ val rawTitle = reelObj?.optString("title")?.takeIf { it.isNotBlank() } ?: json.optString("title", "")
+ val title = when {
+ rawCaption.isNotBlank() -> rawCaption
+ rawTitle.isNotBlank() && !rawTitle.equals("Instagram Reel", ignoreCase = true) -> rawTitle
+ else -> ""
+ }
+ val rawAuthor = reelObj?.optString("author")?.takeIf { it.isNotBlank() } ?: json.optString("author", "")
+ val author = if (rawAuthor.equals("Instagram Creator", ignoreCase = true)) "" else rawAuthor
val thumbnail = reelObj?.optString("thumbnailUrl")?.takeIf { it.isNotBlank() } ?: json.optString("thumbnailUrl", "")
+
+ if (url.isBlank() && code.isNotBlank()) {
+ url = if (code.startsWith("reel_")) "https://www.instagram.com/${code.removePrefix("reel_")}/" else "https://www.instagram.com/reel/$code/"
+ }
+
if (url.isNotBlank()) {
- Log.d(TAG, "Received reelViewed: url=$url, title=$title, author=$author")
+ val isMeta = json.optString("type") == "meta"
+ Log.d(TAG, "Received reelWatched: code=$code, url=$url, author=$author, title=$title, isMeta=$isMeta")
mainHandler.post {
- onReelViewed?.invoke(url, title, author, thumbnail)
+ if (isMeta) {
+ onReelMeta?.invoke(url, title, author)
+ } else {
+ onReelViewed?.invoke(url, title, author, thumbnail)
+ }
}
}
}
+ "downloadMedia" -> {
+ val mediaUrl = json.optString("url")
+ val filename = json.optString("filename")
+ Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename")
+ mainHandler.post {
+ onDownloadRequested?.invoke(mediaUrl, filename)
+ }
+ }
"getSettings" -> {
activePort?.let { sendSettingsToPort(it, cachedSettings) }
}
@@ -182,6 +218,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
put("noAutoplay", settings.noAutoplay)
put("grayscale", settings.grayscaleEnabled)
put("minimalMode", settings.minimalModeEnabled)
+ put("videoDownloadEnabled", settings.videoDownloadEnabled)
put("ghostModeEnabled", settings.ghostModeEnabled)
put("ghostHideStorySeen", settings.ghostHideStorySeen)
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
@@ -289,28 +326,29 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
}
suspend fun setContentBlockerEnabled(enabled: Boolean) {
- val extension = contentBlockerExtension ?: return
- try {
- if (enabled) {
- runtime.webExtensionController
- .enable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
- .await()
- _isContentBlockerActive.value = true
- Log.i(TAG, "Content ad-blocker enabled")
- } else {
- runtime.webExtensionController
- .disable(extension, org.mozilla.geckoview.WebExtensionController.EnableSource.USER)
- .await()
- _isContentBlockerActive.value = false
- Log.i(TAG, "Content ad-blocker disabled")
- }
- } catch (e: Exception) {
- Log.e(TAG, "Failed to toggle content ad-blocker state: ${e.message}", e)
- }
+ _isContentBlockerActive.value = enabled
+ cachedSettings = cachedSettings.copy(noAds = enabled)
+ broadcastSettings(cachedSettings)
+ Log.i(TAG, "Content ad-blocker dynamic state updated: noAds=$enabled")
}
suspend fun setUBlockEnabled(enabled: Boolean) = setContentBlockerEnabled(enabled)
+ /**
+ * Sends request to in-page content script to extract and report active Reel video URL for downloading.
+ */
+ fun triggerReelDownload() {
+ try {
+ val msg = JSONObject().apply {
+ put("action", "requestReelDownload")
+ }
+ activePort?.postMessage(msg)
+ Log.d(TAG, "Sent requestReelDownload to WebExtension port")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send requestReelDownload to port: ${e.message}", e)
+ }
+ }
+
companion object {
private const val TAG = "ExtensionManager"
const val CONTENT_BLOCKER_EXTENSION_ID = "uBlock0@raymondhill.net"
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
new file mode 100644
index 0000000..c8ee433
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramPermissionDelegate.kt
@@ -0,0 +1,87 @@
+package com.ujwal.focusgram.core.engine
+
+import android.content.Context
+import android.content.pm.PackageManager
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.core.content.ContextCompat
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.GeckoSession
+
+/**
+ * FocusGramPermissionDelegate
+ * Handles web content and Android runtime permission requests (Camera, Microphone, Media)
+ * from GeckoView sessions while maintaining user privacy and strict origin isolation.
+ */
+class FocusGramPermissionDelegate(
+ private val context: Context,
+ private val onAndroidPermissionRequest: ((Array, (Boolean) -> Unit) -> Unit)? = null
+) : GeckoSession.PermissionDelegate {
+
+ override fun onAndroidPermissionsRequest(
+ session: GeckoSession,
+ permissions: Array?,
+ callback: GeckoSession.PermissionDelegate.Callback
+ ) {
+ val permsList = permissions?.filterNotNull() ?: emptyList()
+ Log.d(TAG, "onAndroidPermissionsRequest: ${permsList.joinToString()}")
+ val ungranted = permsList.filter {
+ ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
+ }
+
+ if (ungranted.isEmpty()) {
+ callback.grant()
+ return
+ }
+
+ if (onAndroidPermissionRequest != null) {
+ Handler(Looper.getMainLooper()).post {
+ try {
+ onAndroidPermissionRequest.invoke(ungranted.toTypedArray()) { allGranted ->
+ if (allGranted) callback.grant() else callback.reject()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error requesting Android permissions: ${e.message}", e)
+ callback.reject()
+ }
+ }
+ } else {
+ callback.grant()
+ }
+ }
+
+ override fun onContentPermissionRequest(
+ session: GeckoSession,
+ perm: GeckoSession.PermissionDelegate.ContentPermission
+ ): GeckoResult {
+ Log.d(TAG, "onContentPermissionRequest: uri=${perm.uri}, perm=${perm.permission}")
+ val uri = perm.uri?.lowercase() ?: ""
+ return if (uri.contains("instagram.com") || uri.contains("cdninstagram.com")) {
+ GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_ALLOW)
+ } else {
+ GeckoResult.fromValue(GeckoSession.PermissionDelegate.ContentPermission.VALUE_DENY)
+ }
+ }
+
+ override fun onMediaPermissionRequest(
+ session: GeckoSession,
+ uri: String,
+ video: Array?,
+ audio: Array?,
+ callback: GeckoSession.PermissionDelegate.MediaCallback
+ ) {
+ Log.d(TAG, "onMediaPermissionRequest: uri=$uri, video=${video?.size}, audio=${audio?.size}")
+ val videoSource = video?.firstOrNull()
+ val audioSource = audio?.firstOrNull()
+ if (videoSource != null || audioSource != null) {
+ callback.grant(videoSource, audioSource)
+ } else {
+ callback.reject()
+ }
+ }
+
+ companion object {
+ private const val TAG = "PermissionDelegate"
+ }
+}
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 5a941dc..0c9874b 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
@@ -2,6 +2,8 @@ package com.ujwal.focusgram.core.engine
import android.content.Context
import android.net.Uri
+import android.os.Handler
+import android.os.Looper
import android.util.Log
import org.mozilla.geckoview.AllowOrDeny
import org.mozilla.geckoview.Autocomplete
@@ -41,17 +43,26 @@ class FocusGramPromptDelegate(
val result = GeckoResult()
- handler.onPickFiles(mimeTypes, isMultiple) { uris ->
+ // CRITICAL: GeckoView calls onFilePrompt on a background thread.
+ // ActivityResultLauncher.launch() MUST be called on the main Looper thread.
+ Handler(Looper.getMainLooper()).post {
try {
- if (uris.isEmpty()) {
- result.complete(prompt.dismiss())
- } else if (isMultiple) {
- result.complete(prompt.confirm(context, uris.toTypedArray()))
- } else {
- result.complete(prompt.confirm(context, uris.first()))
+ handler.onPickFiles(mimeTypes, isMultiple) { uris ->
+ try {
+ if (uris.isEmpty()) {
+ result.complete(prompt.dismiss())
+ } else if (isMultiple) {
+ result.complete(prompt.confirm(context, uris.toTypedArray()))
+ } else {
+ result.complete(prompt.confirm(context, uris.first()))
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Error confirming file prompt: ${e.message}", e)
+ result.complete(prompt.dismiss())
+ }
}
} catch (e: Exception) {
- Log.e(TAG, "Error confirming file prompt: ${e.message}", e)
+ Log.e(TAG, "Error initiating file picker on UI thread: ${e.message}", e)
result.complete(prompt.dismiss())
}
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt
index 5807145..2a53aff 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoRuntimeManager.kt
@@ -20,6 +20,7 @@ class GeckoRuntimeManager private constructor(context: Context) {
val runtime: GeckoRuntime
val extensionManager: ExtensionManager
+ val reelHistory: com.ujwal.focusgram.feature.history.ReelHistory
init {
val appContext = context.applicationContext
@@ -45,6 +46,7 @@ class GeckoRuntimeManager private constructor(context: Context) {
Log.i(TAG, "Initializing Mozilla GeckoRuntime with strict privacy & WebExtension runtime...")
runtime = GeckoRuntime.create(appContext, runtimeSettings)
extensionManager = ExtensionManager(runtime)
+ reelHistory = com.ujwal.focusgram.feature.history.ReelHistory(java.io.File(appContext.filesDir, "reel_history.json"))
runtime.webExtensionController
.ensureBuiltIn("resource://android/assets/reellock/", "reellock@focusgram.app")
@@ -52,6 +54,16 @@ class GeckoRuntimeManager private constructor(context: Context) {
{ Log.d(TAG, "✓ Reel lock WebExtension installed") },
{ e -> Log.e(TAG, "Reel lock WebExtension installation failed", e) }
)
+
+ runtime.webExtensionController
+ .ensureBuiltIn("resource://android/assets/reelhistory/", "reelhistory@focusgram.app")
+ .accept(
+ { ext ->
+ ext?.setMessageDelegate(com.ujwal.focusgram.feature.history.ReelMessageDelegate(reelHistory), "reelhistory")
+ Log.d(TAG, "✓ Reel history WebExtension installed and delegate registered")
+ },
+ { e -> Log.e(TAG, "Reel history WebExtension installation failed", e) }
+ )
}
companion object {
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 46dcb56..c7ac310 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
@@ -49,7 +49,8 @@ class UserScriptManager(private val context: Context) {
hideShopTab: ${settings.hideShopTab},
blockAutoplay: ${settings.noAutoplay},
grayscale: ${settings.grayscaleEnabled},
- minimalMode: ${settings.minimalModeEnabled}
+ minimalMode: ${settings.minimalModeEnabled},
+ videoDownloadEnabled: ${settings.videoDownloadEnabled}
};
window.__FOCUSGRAM_GHOST_CONFIG__ = {
enabled: ${settings.ghostModeEnabled},
@@ -69,6 +70,13 @@ class UserScriptManager(private val context: Context) {
if (window.__fgSetGrayscale) {
window.__fgSetGrayscale(${settings.grayscaleEnabled});
}
+ if (window.__fgSetFilterConfig) {
+ window.__fgSetFilterConfig({
+ blockAds: ${settings.noAds},
+ blockSponsored: ${settings.noAds},
+ blockSuggested: ${settings.hideSuggestedPosts}
+ });
+ }
document.documentElement.setAttribute('data-fg-block-dm-reels', '${settings.blockDmReelsSwiping}');
window.postMessage({
source: "GHOSTIFY_EXTENSION",
@@ -108,6 +116,7 @@ class UserScriptManager(private val context: Context) {
fun injectAll(session: GeckoSession, settings: FocusSettings) {
val bundle = StringBuilder()
+ bundle.append(loadScript("fetch_interceptor.js")).append("\n")
bundle.append(loadScript("content_hider.js")).append("\n")
bundle.append(loadScript("autoplay_blocker.js")).append("\n")
bundle.append(loadScript("grayscale.js")).append("\n")
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 5d96fef..69b0168 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,7 +58,7 @@ data class FocusSettings(
// Extras & Notifications
val startupPage: String = "home",
- val videoDownloadEnabled: Boolean = false,
+ val videoDownloadEnabled: Boolean = true,
val sanitizeLinks: Boolean = true,
val notifySessionEnd: Boolean = false,
val notifyPersistent: 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 2be9766..dc17322 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
@@ -1,7 +1,9 @@
package com.ujwal.focusgram.core.ui.components
import android.content.Context
+import android.media.AudioAttributes
import android.os.Build
+import android.os.VibrationAttributes
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
@@ -91,22 +93,7 @@ fun CupertinoWheelPicker(
snapshotFlow { currentCenteredIndex }
.collect { index ->
if (index in items.indices) {
- try {
- val performed = view.performHapticFeedback(
- HapticFeedbackConstants.CLOCK_TICK,
- HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
- )
- if (!performed) {
- val vibrator = getVibrator(view.context)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
- } else {
- @Suppress("DEPRECATION")
- vibrator?.vibrate(10L)
- }
- }
- } catch (_: Exception) {}
-
+ performWheelHaptic(view.context, view)
onItemSelected(index)
}
}
@@ -273,22 +260,7 @@ fun CupertinoOptionWheelPicker(
snapshotFlow { currentCenteredIndex }
.collect { index ->
if (index in options.indices) {
- try {
- val performed = view.performHapticFeedback(
- HapticFeedbackConstants.CLOCK_TICK,
- HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
- )
- if (!performed) {
- val vibrator = getVibrator(view.context)
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- vibrator?.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
- } else {
- @Suppress("DEPRECATION")
- vibrator?.vibrate(10L)
- }
- }
- } catch (_: Exception) {}
-
+ performWheelHaptic(view.context, view)
onOptionSelected(index, options[index])
}
}
@@ -408,3 +380,44 @@ private fun getVibrator(context: Context): Vibrator? {
context.getSystemService(Vibrator::class.java)
}
}
+
+/**
+ * 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.
+ */
+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) {}
+}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt
index 42f03a4..6414a40 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/ui/components/IOSSettingsComponents.kt
@@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.automirrored.filled.ArrowForwardIos
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -210,6 +211,7 @@ fun IOSSettingsRow(
subtitle: String? = null,
icon: ImageVector? = null,
iconUrl: String? = null,
+ iconRes: Int? = null,
iconBgColor: Color = IOSBlue,
checked: Boolean? = null,
onCheckedChange: ((Boolean) -> Unit)? = null,
@@ -232,10 +234,26 @@ fun IOSSettingsRow(
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
- if (iconUrl != null && icon != null) {
+ if (iconRes != null) {
+ Box(
+ modifier = Modifier
+ .size(30.dp)
+ .clip(RoundedCornerShape(7.dp))
+ .background(iconBgColor),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = androidx.compose.ui.res.painterResource(id = iconRes),
+ contentDescription = null,
+ tint = Color.Unspecified,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(14.dp))
+ } else if (iconUrl != null) {
DynamicNetworkIcon(
imageUrl = iconUrl,
- fallbackIcon = icon,
+ fallbackIcon = icon ?: Icons.Default.Info,
iconBgColor = iconBgColor
)
Spacer(modifier = Modifier.width(14.dp))
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
new file mode 100644
index 0000000..8841f05
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/MediaDownloadHelper.kt
@@ -0,0 +1,102 @@
+package com.ujwal.focusgram.core.utils
+
+import android.app.DownloadManager
+import android.content.ContentValues
+import android.content.Context
+import android.net.Uri
+import android.os.Build
+import android.os.Environment
+import android.provider.MediaStore
+import android.util.Base64
+import android.util.Log
+import android.widget.Toast
+import java.io.File
+import java.io.FileOutputStream
+
+/**
+ * MediaDownloadHelper
+ * Native client-side media downloader using Android System DownloadManager & MediaStore.
+ * Compliant with on-device architecture: downloads directly to user's public Downloads directory.
+ */
+object MediaDownloadHelper {
+ private const val TAG = "MediaDownloadHelper"
+
+ fun download(context: Context, url: String, filename: String = "") {
+ if (url.isBlank()) {
+ Toast.makeText(context, "No media URL found to download", Toast.LENGTH_SHORT).show()
+ return
+ }
+
+ 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"
+ }
+
+ if (url.startsWith("data:")) {
+ val commaIndex = url.indexOf(',')
+ if (commaIndex != -1) {
+ 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")
+ return
+ }
+ }
+
+ val uri = Uri.parse(url)
+ val request = DownloadManager.Request(uri).apply {
+ setTitle(targetFilename)
+ setDescription("FocusGram Media Download")
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
+ setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, targetFilename)
+ setAllowedOverMetered(true)
+ setAllowedOverRoaming(true)
+ addRequestHeader("User-Agent", "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36")
+ addRequestHeader("Referer", "https://www.instagram.com/")
+ addRequestHeader("Accept", "*/*")
+ }
+
+ 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")
+ } else {
+ Toast.makeText(context, "Download service unavailable", Toast.LENGTH_SHORT).show()
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to download media: ${e.message}", e)
+ Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_SHORT).show()
+ }
+ }
+
+ private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) {
+ val mimeType = if (isVideo) "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.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 itemUri = context.contentResolver.insert(collection, values)
+ if (itemUri != null) {
+ context.contentResolver.openOutputStream(itemUri)?.use { out ->
+ out.write(bytes)
+ }
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
+ val file = File(downloadsDir, filename)
+ FileOutputStream(file).use { out ->
+ out.write(bytes)
+ }
+ }
+ }
+}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt
new file mode 100644
index 0000000..e983a4d
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelHistory.kt
@@ -0,0 +1,212 @@
+package com.ujwal.focusgram.feature.history
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.util.concurrent.Executors
+
+data class ReelEntry(
+ val code: String,
+ val author: String, // may be empty if no source had it
+ val caption: String, // empty is normal, many reels have no caption
+ val lastSeen: Long, // epoch millis
+ val views: Int,
+) {
+ val url: String get() = "https://www.instagram.com/reel/${code.removePrefix("reel_")}/"
+}
+
+class ReelHistory(private val file: File) {
+ private val lock = Any()
+ private val io = Executors.newSingleThreadExecutor()
+ private val sessionCodes = HashSet()
+ private var entries: List = load() // newest first
+
+ var onNewReelWatched: ((String) -> Unit)? = null
+
+ private fun getTodayStartMillis(): Long {
+ val cal = java.util.Calendar.getInstance().apply {
+ set(java.util.Calendar.HOUR_OF_DAY, 0)
+ set(java.util.Calendar.MINUTE, 0)
+ set(java.util.Calendar.SECOND, 0)
+ set(java.util.Calendar.MILLISECOND, 0)
+ }
+ return cal.timeInMillis
+ }
+
+ private fun calculateTodayCount(list: List): Int {
+ val start = getTodayStartMillis()
+ return list.count { it.lastSeen >= start }
+ }
+
+ private val _items = MutableStateFlow(entries)
+ val items: StateFlow> = _items
+
+ private val _sessionCount = MutableStateFlow(0)
+ val sessionCount: StateFlow = _sessionCount // unique reels since app start
+
+ private val _todayCount = MutableStateFlow(calculateTodayCount(entries))
+ val todayCount: StateFlow = _todayCount // unique reels watched today
+
+ companion object {
+ private val IGNORED_CODES = setOf(
+ "home", "explore", "reels", "reel", "direct", "accounts",
+ "stories", "p", "about", "legal", "videos", "audio", "clips",
+ "trending", "foryou", "following", "live"
+ )
+ private val GENERIC_AUTHORS = setOf(
+ "home", "explore", "reels", "reel", "direct", "instagram",
+ "instagram creator", "instagram user", "instagram video", "creator", "user", ""
+ )
+ private val GENERIC_CAPTIONS = setOf(
+ "instagram reel", "reel", "reels", "instagram", "instagram video", "video", ""
+ )
+ }
+
+ private fun sanitizeCaption(rawCaption: String, cleanAuthor: String): String {
+ val clean = rawCaption.trim()
+ val lower = clean.lowercase()
+ val isGeneric = lower.isBlank() ||
+ lower in GENERIC_CAPTIONS ||
+ lower.startsWith("instagram reel") ||
+ lower.startsWith("reel by @") ||
+ lower.startsWith("reel by ") ||
+ (cleanAuthor.isNotBlank() && lower == cleanAuthor.lowercase()) ||
+ lower.endsWith("· original audio") ||
+ lower.endsWith("original audio")
+ return if (isGeneric) "" else clean
+ }
+
+ private fun isCaptionInvalid(caption: String, author: String): Boolean {
+ return sanitizeCaption(caption, author).isBlank()
+ }
+
+ fun onWatched(code: String, author: String, caption: String): Int = synchronized(lock) {
+ val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
+ val lowerCode = cleanCode.lowercase()
+ if (cleanCode.length < 5 || lowerCode in IGNORED_CODES) {
+ return _sessionCount.value
+ }
+ val finalCode = if (cleanCode.startsWith("reel_")) cleanCode.removePrefix("reel_") else cleanCode
+ if (finalCode.length < 5 || finalCode.lowercase() in IGNORED_CODES) {
+ return _sessionCount.value
+ }
+
+ val cleanAuthor = author.trim().removePrefix("@")
+ val lowerAuthor = cleanAuthor.lowercase()
+ val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram")
+ val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor
+
+ val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor)
+
+ val isNew = sessionCodes.add(finalCode)
+ val old = entries.firstOrNull { it.code == finalCode }
+
+ val resolvedAuthor = sanitizedAuthor.ifEmpty { old?.author ?: "" }
+ val resolvedCaption = sanitizedCaption.ifEmpty {
+ val oldCaption = old?.caption ?: ""
+ if (!isCaptionInvalid(oldCaption, resolvedAuthor)) oldCaption else ""
+ }
+
+ val updated = ReelEntry(
+ code = finalCode,
+ author = resolvedAuthor,
+ caption = resolvedCaption,
+ lastSeen = System.currentTimeMillis(),
+ views = (old?.views ?: 0) + 1,
+ )
+ commit(listOf(updated) + entries.filter { it.code != finalCode })
+ _sessionCount.value = sessionCodes.size
+ if (isNew) {
+ onNewReelWatched?.invoke(finalCode)
+ }
+ sessionCodes.size
+ }
+
+ fun onMeta(code: String, author: String, caption: String) {
+ synchronized(lock) {
+ val cleanCode = if (code.startsWith("reel_")) code.removePrefix("reel_") else code
+ if (entries.none { it.code == cleanCode }) return
+
+ val cleanAuthor = author.trim().removePrefix("@")
+ val lowerAuthor = cleanAuthor.lowercase()
+ val isGenericAuthor = lowerAuthor in GENERIC_AUTHORS || lowerAuthor.startsWith("instagram")
+ val sanitizedAuthor = if (isGenericAuthor) "" else cleanAuthor
+
+ val sanitizedCaption = sanitizeCaption(caption, sanitizedAuthor)
+
+ commit(entries.map {
+ if (it.code == cleanCode) {
+ val resolvedAuthor = sanitizedAuthor.ifEmpty { it.author }
+ val currentCaptionInvalid = isCaptionInvalid(it.caption, resolvedAuthor)
+ val resolvedCaption = if (sanitizedCaption.isNotEmpty()) {
+ sanitizedCaption
+ } else if (!currentCaptionInvalid) {
+ it.caption
+ } else {
+ ""
+ }
+ it.copy(
+ author = resolvedAuthor,
+ caption = resolvedCaption
+ )
+ } else it
+ })
+ }
+ }
+
+ fun delete(code: String) = synchronized(lock) { commit(entries.filter { it.code != code }) }
+
+ fun clear() = synchronized(lock) { commit(emptyList()) }
+
+ fun resetSession() = synchronized(lock) {
+ sessionCodes.clear()
+ _sessionCount.value = 0
+ }
+
+ private fun commit(list: List) {
+ entries = list.take(500)
+ _items.value = entries
+ _todayCount.value = calculateTodayCount(entries)
+ val snapshot = entries
+ io.execute { save(snapshot) }
+ }
+
+ private fun save(list: List) {
+ try {
+ val arr = JSONArray()
+ list.forEach {
+ arr.put(
+ JSONObject()
+ .put("code", it.code)
+ .put("author", it.author)
+ .put("caption", it.caption)
+ .put("lastSeen", it.lastSeen)
+ .put("views", it.views)
+ )
+ }
+ val tmp = File(file.parentFile, file.name + ".tmp")
+ tmp.writeText(arr.toString())
+ tmp.renameTo(file)
+ } catch (_: Exception) {}
+ }
+
+ private fun load(): List = try {
+ if (!file.exists()) emptyList() else {
+ val arr = JSONArray(file.readText())
+ (0 until arr.length()).map {
+ val o = arr.getJSONObject(it)
+ ReelEntry(
+ o.getString("code"),
+ o.optString("author"),
+ o.optString("caption"),
+ o.optLong("lastSeen"),
+ o.optInt("views", 1)
+ )
+ }
+ }
+ } catch (_: Exception) {
+ emptyList()
+ }
+}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt
new file mode 100644
index 0000000..e3dcf4c
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelMessageDelegate.kt
@@ -0,0 +1,49 @@
+package com.ujwal.focusgram.feature.history
+
+import org.json.JSONObject
+import org.mozilla.geckoview.GeckoResult
+import org.mozilla.geckoview.WebExtension
+
+class ReelMessageDelegate(private val history: ReelHistory) : WebExtension.MessageDelegate {
+ private val codeRe = Regex("^[A-Za-z0-9_.-]{3,}$")
+
+ override fun onConnect(port: WebExtension.Port) {
+ port.setDelegate(object : WebExtension.PortDelegate {
+ override fun onPortMessage(message: Any, p: WebExtension.Port) {
+ handlePayload(message)
+ }
+ override fun onDisconnect(p: WebExtension.Port) {}
+ })
+ }
+
+ override fun onMessage(
+ nativeApp: String,
+ message: Any,
+ sender: WebExtension.MessageSender,
+ ): GeckoResult? {
+ val count = handlePayload(message) ?: return null
+ return GeckoResult.fromValue(JSONObject().put("count", count))
+ }
+
+ private fun handlePayload(message: Any): Int? {
+ val m = when (message) {
+ is JSONObject -> message
+ is String -> try { JSONObject(message) } catch (_: Exception) { null }
+ else -> null
+ } ?: return null
+
+ val code = m.optString("code")
+ if (!codeRe.matches(code)) return null
+ val author = m.optString("author")
+ val caption = m.optString("caption")
+
+ return when (m.optString("type")) {
+ "watched" -> history.onWatched(code, author, caption)
+ "meta" -> {
+ history.onMeta(code, author, caption)
+ history.sessionCount.value
+ }
+ else -> null
+ }
+ }
+}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt
index 1ca4bcf..2171041 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/history/ReelsHistoryScreen.kt
@@ -48,7 +48,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
-import com.ujwal.focusgram.core.database.ReelsHistoryItem
import com.ujwal.focusgram.core.ui.components.FocusGramCard
import com.ujwal.focusgram.core.ui.components.FocusGramTopBar
import com.ujwal.focusgram.core.ui.theme.AlertRed
@@ -60,7 +59,6 @@ import com.ujwal.focusgram.core.ui.theme.PureBlack
import com.ujwal.focusgram.core.ui.theme.TextMuted
import com.ujwal.focusgram.core.ui.theme.TextPrimary
import com.ujwal.focusgram.core.ui.theme.TextSecondary
-import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -72,7 +70,7 @@ import java.util.Locale
*/
@Composable
fun ReelsHistoryScreen(
- repository: ReelsHistoryRepository,
+ reelHistory: ReelHistory,
onBack: () -> Unit,
onOpenReel: (String) -> Unit,
modifier: Modifier = Modifier
@@ -82,13 +80,20 @@ fun ReelsHistoryScreen(
}
var searchQuery by remember { mutableStateOf("") }
- val scope = rememberCoroutineScope()
var showClearDialog by remember { mutableStateOf(false) }
- val reels by if (searchQuery.isBlank()) {
- repository.allReels.collectAsState(initial = emptyList())
- } else {
- repository.searchReels(searchQuery).collectAsState(initial = emptyList())
+ val allReels by reelHistory.items.collectAsState()
+ val reels = remember(allReels, searchQuery) {
+ if (searchQuery.isBlank()) {
+ allReels
+ } else {
+ val q = searchQuery.trim().lowercase()
+ allReels.filter {
+ it.author.lowercase().contains(q) ||
+ it.caption.lowercase().contains(q) ||
+ it.code.lowercase().contains(q)
+ }
+ }
}
Column(
@@ -101,7 +106,7 @@ fun ReelsHistoryScreen(
title = "Reels History",
onBack = onBack,
actions = {
- if (reels.isNotEmpty()) {
+ if (allReels.isNotEmpty()) {
IconButton(onClick = { showClearDialog = true }) {
Icon(
imageVector = Icons.Default.Delete,
@@ -113,32 +118,12 @@ fun ReelsHistoryScreen(
}
)
- // Privacy indicator banner
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 6.dp),
- verticalAlignment = Alignment.CenterVertically
- ) {
- Icon(
- imageVector = Icons.Default.Lock,
- contentDescription = null,
- tint = TextMuted,
- modifier = Modifier.size(14.dp)
- )
- Spacer(modifier = Modifier.width(6.dp))
- Text(
- text = "${reels.size} reels stored locally on-device only",
- style = MaterialTheme.typography.bodySmall,
- color = TextMuted
- )
- }
// Local search filter field
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
- placeholder = { Text("Search by title or URL...", color = TextMuted) },
+ placeholder = { Text("Search by author or caption...", color = TextMuted) },
leadingIcon = {
Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted)
},
@@ -186,7 +171,7 @@ fun ReelsHistoryScreen(
)
Spacer(modifier = Modifier.height(4.dp))
Text(
- text = "Watched reels will automatically record metadata here.",
+ text = "Watched reels will automatically record author and caption here.",
style = MaterialTheme.typography.bodySmall,
color = TextMuted
)
@@ -199,11 +184,11 @@ fun ReelsHistoryScreen(
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
- items(reels, key = { it.id }) { item ->
+ items(reels, key = { it.code }) { item ->
ReelHistoryCard(
item = item,
onClick = { onOpenReel(item.url) },
- onDelete = { scope.launch { repository.deleteReel(item.id) } }
+ onDelete = { reelHistory.delete(item.code) }
)
}
}
@@ -216,7 +201,7 @@ fun ReelsHistoryScreen(
title = { Text("Clear Reels History?", color = TextPrimary) },
text = {
Text(
- "This removes all locally recorded reel visit history from SQLite.",
+ "This removes all locally recorded reel visit history.",
color = TextSecondary
)
},
@@ -224,7 +209,7 @@ fun ReelsHistoryScreen(
confirmButton = {
Button(
onClick = {
- scope.launch { repository.clearHistory() }
+ reelHistory.clear()
showClearDialog = false
},
colors = ButtonDefaults.buttonColors(containerColor = AlertRed)
@@ -243,7 +228,7 @@ fun ReelsHistoryScreen(
@Composable
private fun ReelHistoryCard(
- item: ReelsHistoryItem,
+ item: ReelEntry,
onClick: () -> Unit,
onDelete: () -> Unit
) {
@@ -276,19 +261,31 @@ private fun ReelHistoryCard(
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
+ if (item.author.isNotBlank()) {
+ Text(
+ text = "@${item.author}",
+ style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
+ color = InstagramBlue,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Spacer(modifier = Modifier.height(2.dp))
+ }
Text(
- text = item.title,
- style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+ text = if (item.caption.isNotBlank()) item.caption else "Reel /${item.code}",
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Normal),
color = TextPrimary,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
- Text(
- text = formatRelativeTime(item.visitedAt),
- style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
- color = TextMuted
- )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ text = formatRelativeTime(item.lastSeen),
+ style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
+ color = TextMuted
+ )
+ }
}
IconButton(onClick = onDelete) {
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 785acfd..4634ad6 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
@@ -148,20 +148,20 @@ fun NativeBottomNavBar(
}
val islandBackground by animateColorAsState(
targetValue = if (islandMode == IslandMode.FLOATING) {
- if (isLight) Color(0xF2FFFFFF) else Color(0xD9101012)
+ if (isLight) Color(0x9CFFFFFF) else Color(0x8B101014)
} else {
- if (isLight) Color(0xF5FFFFFF) else Color(0xF2141416)
+ if (isLight) Color(0x9EFFFFFF) else Color(0x9B141418)
},
animationSpec = tween(300),
label = "islandBackground"
)
val borderColor by animateColorAsState(
- targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
+ targetValue = if (isLight) Color(0x2B000000) else Color(0x4DFFFFFF),
animationSpec = tween(300),
label = "borderColor"
)
val selectedTabBackground by animateColorAsState(
- targetValue = if (isLight) Color(0xFFE5E5EA) else Color(0xFF2C2C30),
+ targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
animationSpec = tween(300),
label = "selectedTabBackground"
)
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt
index d101758..37c6c98 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt
@@ -25,16 +25,29 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.res.painterResource
+import com.ujwal.focusgram.R
+
/**
* ReelsTopBar
- * Minimal Instagram Reels top header matching modern Instagram:
- * - "Reels" (Bold white header)
+ * Minimal Instagram Reels top header:
+ * - "Reels" (Bold white header on left)
+ * - Session watched counter (pill in middle)
+ * - Download icon button (on right, opposite side of Reels)
* - Subtle vertical scrim gradient for legibility over video frames.
*/
@Composable
fun ReelsTopBar(
isVisible: Boolean,
+ watchedCount: Int = 0,
+ isDownloadEnabled: Boolean = false,
onReelsClick: () -> Unit,
+ onDownloadClick: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
@@ -61,7 +74,7 @@ fun ReelsTopBar(
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.Start
+ horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Reels",
@@ -74,6 +87,45 @@ fun ReelsTopBar(
onClick = onReelsClick
)
)
+
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(16.dp))
+ .background(Color(0x40000000))
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "$watchedCount watched",
+ color = Color(0xEEFFFFFF),
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Medium
+ )
+ }
+
+ if (isDownloadEnabled) {
+ Box(
+ modifier = Modifier
+ .size(36.dp)
+ .clip(CircleShape)
+ .background(Color(0x26FFFFFF))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onDownloadClick
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ painter = painterResource(id = R.drawable.ic_download_fill),
+ contentDescription = "Download Reel",
+ tint = Color.White,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ } else {
+ androidx.compose.foundation.layout.Spacer(modifier = Modifier.size(36.dp))
+ }
}
}
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt
index c5b37af..05767f1 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/feature/screentime/ScreenTimeScreen.kt
@@ -1,5 +1,6 @@
package com.ujwal.focusgram.feature.screentime
+import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -39,6 +40,8 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOf
+import com.ujwal.focusgram.feature.history.ReelHistory
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -46,6 +49,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ujwal.focusgram.core.database.ScreenTimeDao
@@ -62,20 +66,29 @@ import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
+import androidx.compose.foundation.clickable
+import androidx.compose.material3.DatePickerDialog
+import androidx.compose.material3.DateRangePicker
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberDateRangePickerState
+import androidx.compose.runtime.mutableStateOf
import kotlin.math.max
-import androidx.activity.compose.BackHandler
+enum class DailyBreakdownRange(val label: String) {
+ PAST_7_DAYS("7 Days"),
+ PAST_14_DAYS("14 Days"),
+ PAST_30_DAYS("30 Days"),
+ CUSTOM("Custom")
+}
-/**
- * ScreenTimeScreen
- * Visualizes user digital wellness trends, daily screen time, and session limit adherence.
- * Designed with authentic iOS Screen Time aesthetics and on-device Room SQLite records.
- */
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScreenTimeScreen(
screenTimeDao: ScreenTimeDao,
dailyLimitMinutes: Int = 60,
onBack: () -> Unit,
+ reelHistory: ReelHistory? = null,
modifier: Modifier = Modifier
) {
BackHandler {
@@ -87,35 +100,31 @@ fun ScreenTimeScreen(
val dayLabelFormat = remember { SimpleDateFormat("EEE", Locale.US) }
val todayStr = remember { dateFormat.format(Date()) }
- var liveOffset by remember { mutableIntStateOf(0) }
- LaunchedEffect(records) {
- liveOffset = 0
- }
- LaunchedEffect(Unit) {
- while (true) {
- delay(1000L)
- liveOffset++
- }
- }
-
val todayRecord = records.firstOrNull { it.date == todayStr }
- val todaySeconds = (todayRecord?.seconds ?: 0) + liveOffset
+ val todayWatchedFromHistory by (reelHistory?.todayCount ?: flowOf(0)).collectAsState(initial = 0)
+ val todaySeconds = todayRecord?.seconds ?: 0
val todayReelsSeconds = todayRecord?.reelsSeconds ?: 0
val todayMinutes = todaySeconds / 60
val todayReelsMinutes = todayReelsSeconds / 60
+ val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0)
val limitFraction = (todayMinutes.toFloat() / max(1, dailyLimitMinutes)).coerceIn(0f, 1f)
+ var selectedRange by remember { mutableStateOf(DailyBreakdownRange.PAST_7_DAYS) }
+ var customStartDateMillis by remember { mutableStateOf(null) }
+ var customEndDateMillis by remember { mutableStateOf(null) }
+ var showCustomPicker by remember { mutableStateOf(false) }
+
val todayUsageText = when {
todaySeconds < 60 -> "${todaySeconds}s"
- todaySeconds % 60 == 0 -> "$todayMinutes min"
- else -> "$todayMinutes min ${todaySeconds % 60}s"
+ todaySeconds % 60 == 0 -> "${todayMinutes}m"
+ else -> "${todayMinutes}m ${todaySeconds % 60}s"
}
val todayReelsText = when {
- todayReelsSeconds == 0 -> "0 min"
+ todayReelsSeconds == 0 -> "0m"
todayReelsSeconds < 60 -> "${todayReelsSeconds}s"
- todayReelsSeconds % 60 == 0 -> "$todayReelsMinutes min"
- else -> "$todayReelsMinutes min ${todayReelsSeconds % 60}s"
+ todayReelsSeconds % 60 == 0 -> "${todayReelsMinutes}m"
+ else -> "${todayReelsMinutes}m ${todayReelsSeconds % 60}s"
}
// Compute past 7 days breakdown for bar chart
@@ -139,6 +148,110 @@ fun ScreenTimeScreen(
val weeklyTotalMinutes = last7Days.sumOf { it.second }
val weeklyDailyAverage = weeklyTotalMinutes / 7
+ val displayedRecords = remember(records, selectedRange, customStartDateMillis, customEndDateMillis) {
+ when (selectedRange) {
+ DailyBreakdownRange.PAST_7_DAYS -> {
+ val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -6) }
+ val cutoffStr = dateFormat.format(cutoffCal.time)
+ records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
+ }
+ DailyBreakdownRange.PAST_14_DAYS -> {
+ val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -13) }
+ val cutoffStr = dateFormat.format(cutoffCal.time)
+ records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
+ }
+ DailyBreakdownRange.PAST_30_DAYS -> {
+ val cutoffCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -29) }
+ val cutoffStr = dateFormat.format(cutoffCal.time)
+ records.filter { it.date >= cutoffStr && it.date <= todayStr }.sortedByDescending { it.date }
+ }
+ DailyBreakdownRange.CUSTOM -> {
+ val startStr = customStartDateMillis?.let { dateFormat.format(Date(it)) } ?: "0000-00-00"
+ val endStr = customEndDateMillis?.let { dateFormat.format(Date(it)) } ?: "9999-99-99"
+ records.filter { it.date in startStr..endStr }.sortedByDescending { it.date }
+ }
+ }
+ }
+
+ val distinctYears = remember(displayedRecords) {
+ displayedRecords.mapNotNull { it.date.split("-").getOrNull(0) }.distinct()
+ }
+ val distinctMonths = remember(displayedRecords) {
+ displayedRecords.mapNotNull {
+ val parts = it.date.split("-")
+ if (parts.size >= 2) "${parts[0]}-${parts[1]}" else null
+ }.distinct()
+ }
+ val hasMultipleYears = distinctYears.size > 1
+ val hasMultipleMonths = distinctMonths.size > 1
+
+ val yesterdayStr = remember {
+ val yCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
+ dateFormat.format(yCal.time)
+ }
+ val sevenDaysAgoStr = remember {
+ val sCal = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -6) }
+ dateFormat.format(sCal.time)
+ }
+
+ val fullDayFormat = remember { SimpleDateFormat("EEEE", Locale.US) }
+ val monthDayFormat = remember { SimpleDateFormat("MMM d", Locale.US) }
+ val monthDayYearFormat = remember { SimpleDateFormat("MMM d, yyyy", Locale.US) }
+
+ fun formatRecordDate(dateStr: String): String {
+ if (dateStr == todayStr) return "Today"
+ if (dateStr == yesterdayStr) return "Yesterday"
+ val parsed = try { dateFormat.parse(dateStr) } catch (_: Exception) { null } ?: return dateStr
+
+ // Within past 7 days: show day of the week
+ if (dateStr >= sevenDaysAgoStr && dateStr <= todayStr) {
+ return fullDayFormat.format(parsed)
+ }
+
+ // Only show month & year if it includes multiple months and years
+ return when {
+ hasMultipleYears -> monthDayYearFormat.format(parsed)
+ hasMultipleMonths -> monthDayFormat.format(parsed)
+ else -> fullDayFormat.format(parsed)
+ }
+ }
+
+ if (showCustomPicker) {
+ val pickerState = rememberDateRangePickerState()
+ DatePickerDialog(
+ onDismissRequest = { showCustomPicker = false },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ val start = pickerState.selectedStartDateMillis
+ val end = pickerState.selectedEndDateMillis ?: start
+ if (start != null) {
+ customStartDateMillis = start
+ customEndDateMillis = end
+ selectedRange = DailyBreakdownRange.CUSTOM
+ }
+ showCustomPicker = false
+ }
+ ) {
+ Text("Apply", color = IOSBlue)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showCustomPicker = false }) {
+ Text("Cancel", color = IOSTextSecondary)
+ }
+ }
+ ) {
+ DateRangePicker(
+ state = pickerState,
+ title = { Text("Select Date Range", modifier = Modifier.padding(16.dp), color = IOSTextPrimary) },
+ headline = { },
+ showModeToggle = false,
+ modifier = Modifier.weight(1f)
+ )
+ }
+ }
+
Column(
modifier = modifier
.fillMaxSize()
@@ -183,10 +296,13 @@ fun ScreenTimeScreen(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.weight(1f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Box(
modifier = Modifier
- .size(40.dp)
+ .size(32.dp)
.clip(CircleShape)
.background(IOSTeal.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
@@ -195,30 +311,36 @@ fun ScreenTimeScreen(
imageVector = Icons.Default.HourglassTop,
contentDescription = null,
tint = IOSTeal,
- modifier = Modifier.size(22.dp)
+ modifier = Modifier.size(18.dp)
)
}
- Spacer(modifier = Modifier.width(10.dp))
+ Spacer(modifier = Modifier.width(6.dp))
Column {
Text(
- text = "Today's Usage",
+ text = "Usage",
color = IOSTextSecondary,
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1
)
Text(
text = todayUsageText,
color = IOSTextPrimary,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
}
}
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ modifier = Modifier.weight(1.05f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
Box(
modifier = Modifier
- .size(40.dp)
+ .size(32.dp)
.clip(CircleShape)
.background(IOSPurple.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center
@@ -227,22 +349,63 @@ fun ScreenTimeScreen(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
tint = IOSPurple,
- modifier = Modifier.size(22.dp)
+ modifier = Modifier.size(18.dp)
)
}
- Spacer(modifier = Modifier.width(10.dp))
+ Spacer(modifier = Modifier.width(6.dp))
Column {
Text(
text = "Reels Time",
color = IOSTextSecondary,
- fontSize = 12.sp,
- fontWeight = FontWeight.Medium
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1
)
Text(
text = todayReelsText,
color = IOSTextPrimary,
- fontSize = 20.sp,
- fontWeight = FontWeight.Bold
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Row(
+ modifier = Modifier.weight(0.95f),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Box(
+ modifier = Modifier
+ .size(32.dp)
+ .clip(CircleShape)
+ .background(IOSOrange.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center
+ ) {
+ Icon(
+ imageVector = Icons.Default.Insights,
+ contentDescription = null,
+ tint = IOSOrange,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ Spacer(modifier = Modifier.width(6.dp))
+ Column {
+ Text(
+ text = "Watched",
+ color = IOSTextSecondary,
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1
+ )
+ Text(
+ text = "$todayReelsWatched",
+ color = IOSTextPrimary,
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
)
}
}
@@ -396,29 +559,62 @@ fun ScreenTimeScreen(
}
}
- // Recent Daily Log
+ // Recent Daily Log with customizable ranges
item {
- Text(
- text = "DAILY BREAKDOWN",
- color = IOSTextSecondary,
- fontSize = 12.sp,
- fontWeight = FontWeight.SemiBold,
- modifier = Modifier.padding(start = 4.dp, top = 4.dp)
- )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 4.dp, end = 4.dp, top = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "DAILY BREAKDOWN",
+ color = IOSTextSecondary,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold
+ )
+
+ Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+ DailyBreakdownRange.values().forEach { range ->
+ val isSelected = selectedRange == range
+ Box(
+ modifier = Modifier
+ .clip(RoundedCornerShape(6.dp))
+ .background(if (isSelected) IOSBlue else Color(0xFF2C2C2E))
+ .clickable {
+ if (range == DailyBreakdownRange.CUSTOM) {
+ showCustomPicker = true
+ } else {
+ selectedRange = range
+ }
+ }
+ .padding(horizontal = 7.dp, vertical = 3.dp)
+ ) {
+ Text(
+ text = range.label,
+ color = if (isSelected) Color.White else IOSTextSecondary,
+ fontSize = 10.sp,
+ fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal
+ )
+ }
+ }
+ }
+ }
Spacer(modifier = Modifier.height(6.dp))
IOSGroupedCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
- if (records.isEmpty()) {
+ if (displayedRecords.isEmpty()) {
Text(
- text = "No screen time recorded yet today.",
+ text = "No screen time recorded for this period.",
color = IOSTextSecondary,
fontSize = 14.sp,
modifier = Modifier.padding(vertical = 12.dp)
)
} else {
- records.take(7).forEachIndexed { idx, record ->
+ displayedRecords.forEachIndexed { idx, record ->
val mins = record.seconds / 60
val reelsM = record.reelsSeconds / 60
val reelsS = record.reelsSeconds % 60
@@ -432,14 +628,22 @@ fun ScreenTimeScreen(
) {
Column {
Text(
- text = if (record.date == todayStr) "Today" else record.date,
+ text = formatRecordDate(record.date),
color = IOSTextPrimary,
fontSize = 15.sp
)
+ val subtitleParts = mutableListOf()
if (record.reelsSeconds > 0) {
val reelsLabel = if (record.reelsSeconds < 60) "${reelsS}s reels" else "${reelsM}m reels"
+ subtitleParts.add(reelsLabel)
+ }
+ val watchedCount = if (record.date == todayStr && reelHistory != null) todayReelsWatched else record.reelsWatched
+ if (watchedCount > 0) {
+ subtitleParts.add("$watchedCount watched")
+ }
+ if (subtitleParts.isNotEmpty()) {
Text(
- text = reelsLabel,
+ text = subtitleParts.joinToString(" • "),
color = IOSPurple,
fontSize = 12.sp,
fontWeight = FontWeight.Medium
@@ -453,7 +657,7 @@ fun ScreenTimeScreen(
fontWeight = FontWeight.Medium
)
}
- if (idx < records.take(7).lastIndex) {
+ if (idx < displayedRecords.lastIndex) {
Box(
modifier = Modifier
.fillMaxWidth()
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 34c49bb..c1e1470 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,7 @@ package com.ujwal.focusgram.feature.settings
import android.content.Intent
import android.net.Uri
+import com.ujwal.focusgram.R
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
@@ -92,6 +93,7 @@ import com.ujwal.focusgram.core.ui.components.IOSSettingsRow
import com.ujwal.focusgram.core.ui.components.IOSTeal
import com.ujwal.focusgram.core.ui.components.IOSTextPrimary
import com.ujwal.focusgram.core.ui.components.IOSTextSecondary
+import com.ujwal.focusgram.feature.history.ReelHistory
import com.ujwal.focusgram.feature.session.AppSessionPicker
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@@ -126,6 +128,7 @@ fun SettingsScreen(
onWatchAd: () -> Unit = {},
onNavigateUrl: (String) -> Unit = {},
screenTimeDao: ScreenTimeDao? = null,
+ reelHistory: ReelHistory? = null,
modifier: Modifier = Modifier
) {
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
@@ -325,7 +328,8 @@ fun SettingsScreen(
onWatchAd = onWatchAd,
onShowLegal = { showLegalDialog = true },
onShowDeveloper = { showDeveloperDialog = true },
- screenTimeDao = screenTimeDao
+ screenTimeDao = screenTimeDao,
+ reelHistory = reelHistory
)
}
SettingsSubpage.FOCUS -> {
@@ -378,26 +382,18 @@ private fun MainSettingsContent(
onWatchAd: () -> Unit,
onShowLegal: () -> Unit,
onShowDeveloper: () -> Unit,
- screenTimeDao: ScreenTimeDao? = null
+ screenTimeDao: ScreenTimeDao? = null,
+ reelHistory: ReelHistory? = null
) {
val context = LocalContext.current
val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd", Locale.US) }
val todayStr = remember { dateFormat.format(Date()) }
val todayRecord by (screenTimeDao?.getForDate(todayStr) ?: flowOf(null)).collectAsState(initial = null)
+ val todayWatchedFromHistory by (reelHistory?.todayCount ?: flowOf(0)).collectAsState(initial = 0)
- var liveOffset by remember { mutableIntStateOf(0) }
- LaunchedEffect(todayRecord) {
- liveOffset = 0
- }
- LaunchedEffect(Unit) {
- while (true) {
- delay(1000L)
- liveOffset++
- }
- }
-
- val todaySeconds = (todayRecord?.seconds ?: (settings.instaMinutes * 60)) + liveOffset
+ val todaySeconds = todayRecord?.seconds ?: (settings.instaMinutes * 60)
val todayReelsSeconds = todayRecord?.reelsSeconds ?: (settings.reelsMinutes * 60)
+ val todayReelsWatched = if (reelHistory != null) todayWatchedFromHistory else (todayRecord?.reelsWatched ?: 0)
val appTimeText = when {
todaySeconds < 60 -> "${todaySeconds}s"
@@ -539,7 +535,7 @@ private fun MainSettingsContent(
Row(
modifier = Modifier
.fillMaxWidth()
- .padding(horizontal = 24.dp, vertical = 12.dp),
+ .padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically
) {
@@ -552,6 +548,11 @@ private fun MainSettingsContent(
Text(text = reelsTimeText, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(text = "Reels Time", color = Color(0xFF8E8E93), fontSize = 11.sp)
}
+ Box(modifier = Modifier.width(1.dp).height(24.dp).background(Color(0xFF2C2C2E)))
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Text(text = "$todayReelsWatched", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 16.sp)
+ Text(text = "Reels Watched", color = Color(0xFF8E8E93), fontSize = 11.sp)
+ }
}
}
}
@@ -1607,9 +1608,8 @@ private fun LicensesSubpage() {
IOSSettingsRow(
title = "Mozilla GeckoView",
subtitle = "Mozilla Public License 2.0 · Independent privacy-focused Android browser engine",
- icon = Icons.Default.Security,
- iconUrl = "https://avatars.githubusercontent.com/u/131524?s=120",
- iconBgColor = IOSOrange,
+ iconRes = R.drawable.ic_credit_geckoview,
+ iconBgColor = Color(0xFF008787),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://mozilla.github.io/geckoview/"))
context.startActivity(intent)
@@ -1618,9 +1618,8 @@ private fun LicensesSubpage() {
IOSSettingsRow(
title = "uBlock Origin",
subtitle = "GPLv3 · High-performance content filtering and declarative rule evaluation",
- icon = Icons.Default.Block,
- iconUrl = "https://avatars.githubusercontent.com/u/585534?s=120",
- iconBgColor = IOSRed,
+ iconRes = R.drawable.ic_credit_ublock,
+ iconBgColor = Color(0xFF800000),
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/gorhill/uBlock"))
context.startActivity(intent)
@@ -1630,27 +1629,35 @@ private fun LicensesSubpage() {
title = "Ghostify",
subtitle = "MIT License · Groundbreaking research & telemetry suppression architecture",
icon = Icons.Default.VisibilityOff,
- iconUrl = "https://avatars.githubusercontent.com/u/131580970?s=120",
+ iconUrl = "https://lh3.googleusercontent.com/or_aQHbJ4j41UjtaQ9rsNZk93tPH9Xw_YASD3l-27SW3A_z8Qu1va8JOEcRKyDCRpcVdo1MncJta70iMqSBTmypRbtQ=s60",
iconBgColor = IOSPurple,
onClick = {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://github.com/Hendrizzzz/Ghostify"))
context.startActivity(intent)
}
)
+ IOSSettingsRow(
+ title = "Iconify",
+ subtitle = "Apache 2.0 · Universal icon framework powering bottom nav and system icons",
+ iconRes = R.drawable.ic_credit_iconify,
+ iconBgColor = Color(0xFF026C9C),
+ onClick = {
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://icon-sets.iconify.design/"))
+ context.startActivity(intent)
+ }
+ )
IOSSettingsRow(
title = "Android Jetpack & Compose",
subtitle = "Apache 2.0 · Declarative UI framework, Navigation, and Lifecycle libraries",
- icon = Icons.Default.Settings,
- iconUrl = "https://avatars.githubusercontent.com/u/32689599?s=120",
- iconBgColor = IOSGreen,
+ iconRes = R.drawable.ic_credit_jetpack_compose,
+ iconBgColor = Color(0xFF1C1C1E),
onClick = {}
)
IOSSettingsRow(
title = "Room Database & SQLite",
subtitle = "Apache 2.0 · On-device SQLite persistence and screen time tracking",
- icon = Icons.Default.Timer,
- iconUrl = "https://avatars.githubusercontent.com/u/1715421?s=120",
- iconBgColor = IOSTeal,
+ iconRes = R.drawable.ic_credit_sqlite,
+ iconBgColor = Color(0xFFE8F4FC),
onClick = {}
)
IOSSettingsRow(
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 3a9926c..ea9e558 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
@@ -23,8 +23,11 @@ import android.os.Build
import androidx.core.app.NotificationCompat
import com.ujwal.focusgram.MainActivity
import com.ujwal.focusgram.R
+import com.ujwal.focusgram.core.utils.MediaDownloadHelper
import com.ujwal.focusgram.feature.screentime.ScreenTimeService
import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
@@ -64,6 +67,7 @@ import kotlinx.coroutines.withContext
import com.ujwal.focusgram.core.database.FocusGramDatabase
import com.ujwal.focusgram.core.datastore.SettingsRepository
import com.ujwal.focusgram.core.engine.FocusGramNavigationDelegate
+import com.ujwal.focusgram.core.engine.FocusGramPermissionDelegate
import com.ujwal.focusgram.core.engine.FocusGramPromptDelegate
import com.ujwal.focusgram.core.engine.GeckoRuntimeManager
import com.ujwal.focusgram.core.engine.GeckoSessionManager
@@ -131,6 +135,8 @@ fun FocusGramApp() {
val geckoManager = remember { GeckoRuntimeManager.getInstance(context) }
val runtime = geckoManager.runtime
val extensionManager = geckoManager.extensionManager
+ val reelHistory = remember { geckoManager.reelHistory }
+ val reelsWatchedSessionCount by reelHistory.sessionCount.collectAsState()
val userScriptManager = remember { UserScriptManager(context) }
val sessionManager = remember {
GeckoSessionManager(runtime).apply {
@@ -179,27 +185,49 @@ fun FocusGramApp() {
var adExtensionsUsed by remember { mutableIntStateOf(0) }
var isSessionExhausted by remember { mutableStateOf(false) }
+ // Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active
+ val isGateActive = shouldShowLock ||
+ ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) ||
+ (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) ||
+ (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) ||
+ (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) ||
+ !hasPassedSessionPicker
+
// System File Chooser / Gallery Launchers for Post & Story uploads
var pendingFilePickerCallback by remember { mutableStateOf<((List) -> Unit)?>(null) }
- val multiFilePickerLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.OpenMultipleDocuments()
- ) { uris: List ->
+ val filePickerLauncher = 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) {
+ for (i in 0 until clipData.itemCount) {
+ clipData.getItemAt(i).uri?.let { uris.add(it) }
+ }
+ } else {
+ data.data?.let { uris.add(it) }
+ }
+ }
pendingFilePickerCallback?.invoke(uris)
pendingFilePickerCallback = null
}
- val singleFilePickerLauncher = rememberLauncherForActivityResult(
- contract = ActivityResultContracts.OpenDocument()
- ) { uri: Uri? ->
- pendingFilePickerCallback?.invoke(if (uri != null) listOf(uri) else emptyList())
- pendingFilePickerCallback = null
+ // Android Runtime Permissions Launcher (Camera, Audio, Storage)
+ var pendingPermissionCallback by remember { mutableStateOf<((Boolean) -> Unit)?>(null) }
+ val permissionLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.RequestMultiplePermissions()
+ ) { results ->
+ val allGranted = results.values.all { it }
+ pendingPermissionCallback?.invoke(allGranted)
+ pendingPermissionCallback = null
}
val filePickerHandler = remember {
object : FocusGramPromptDelegate.FilePickerHandler {
- var launchMulti: ((Array) -> Unit)? = null
- var launchSingle: ((Array) -> Unit)? = null
+ var launchChooser: ((Array, Boolean) -> Unit)? = null
override fun onPickFiles(
mimeTypes: Array,
@@ -207,13 +235,8 @@ fun FocusGramApp() {
onResult: (List) -> Unit
) {
pendingFilePickerCallback = onResult
- val safeMimes = if (mimeTypes.isEmpty()) arrayOf("*/*") else mimeTypes
try {
- if (allowMultiple) {
- launchMulti?.invoke(safeMimes) ?: onResult(emptyList())
- } else {
- launchSingle?.invoke(safeMimes) ?: onResult(emptyList())
- }
+ launchChooser?.invoke(mimeTypes, allowMultiple) ?: onResult(emptyList())
} catch (e: Exception) {
Log.e("FocusGramApp", "Error launching file picker: ${e.message}", e)
onResult(emptyList())
@@ -221,8 +244,55 @@ fun FocusGramApp() {
}
}
}
- filePickerHandler.launchMulti = { mimes -> multiFilePickerLauncher.launch(mimes) }
- filePickerHandler.launchSingle = { mimes -> singleFilePickerLauncher.launch(mimes) }
+
+ filePickerHandler.launchChooser = { mimes, allowMultiple ->
+ val mimeMap = android.webkit.MimeTypeMap.getSingleton()
+ val cleanMimes = mimes
+ .flatMap { it.split(",") }
+ .map { it.trim() }
+ .filter { it.isNotEmpty() }
+ .mapNotNull { token ->
+ if (token.startsWith(".")) {
+ mimeMap.getMimeTypeFromExtension(token.removePrefix("."))
+ } else if (token.contains("/")) {
+ token
+ } else {
+ null
+ }
+ }
+ .distinct()
+ .toTypedArray()
+
+ val hasImages = cleanMimes.any { it.startsWith("image/") } || cleanMimes.isEmpty()
+ val hasVideos = cleanMimes.any { it.startsWith("video/") }
+
+ 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 chooser = android.content.Intent.createChooser(intent, "Select Media").apply {
+ addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ filePickerLauncher.launch(chooser)
+ }
// Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView)
LaunchedEffect(Unit) {
@@ -316,9 +386,9 @@ fun FocusGramApp() {
}
}
- // Media pausing: pause media when leaving webview, resume when returning
- LaunchedEffect(destination) {
- if (destination != AppDestination.MAIN_WEBVIEW) {
+ // Media pausing: pause media when leaving webview or when barriers/gates are active, resume when returning
+ LaunchedEffect(destination, isGateActive) {
+ if (destination != AppDestination.MAIN_WEBVIEW || isGateActive) {
sessionManager.pauseMedia()
} else {
sessionManager.resumeMedia()
@@ -339,7 +409,7 @@ fun FocusGramApp() {
}
} else if (event == Lifecycle.Event.ON_START) {
isAppInForeground = true
- if (destination == AppDestination.MAIN_WEBVIEW) {
+ if (destination == AppDestination.MAIN_WEBVIEW && !isGateActive) {
sessionManager.resumeMedia()
}
}
@@ -378,10 +448,48 @@ fun FocusGramApp() {
sessionManager.updateTheme(theme)
}
extensionManager.onReelViewed = { url, title, author, thumb ->
+ val code = if (url.contains("/reel/")) {
+ url.substringAfter("/reel/").substringBefore("/").substringBefore("?")
+ } else if (url.contains("/reels/")) {
+ url.substringAfter("/reels/").substringBefore("/").substringBefore("?")
+ } else {
+ url.trimEnd('/').substringAfterLast('/')
+ }
+ val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
+ val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories")
+ if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) {
+ reelHistory.onWatched(cleanCode, author, title)
+ }
scope.launch {
reelsRepository.addReel(url, title, thumb)
}
}
+ extensionManager.onReelMeta = { url, title, author ->
+ val code = if (url.contains("/reel/")) {
+ url.substringAfter("/reel/").substringBefore("/").substringBefore("?")
+ } else if (url.contains("/reels/")) {
+ url.substringAfter("/reels/").substringBefore("/").substringBefore("?")
+ } else {
+ url.trimEnd('/').substringAfterLast('/')
+ }
+ val cleanCode = code.trim().removePrefix("/").removeSuffix("/")
+ val ignoredCodes = setOf("reels", "reel", "videos", "audio", "clips", "home", "explore", "direct", "stories")
+ if (cleanCode.length >= 5 && cleanCode.lowercase() !in ignoredCodes) {
+ if (title.isNotBlank() || author.isNotBlank()) {
+ reelHistory.onMeta(cleanCode, author, title)
+ }
+ }
+ }
+ extensionManager.onDownloadRequested = { url, filename ->
+ MediaDownloadHelper.download(context, url, filename)
+ }
+ }
+
+ LaunchedEffect(reelHistory, database) {
+ val todayStr = dateFormat.format(java.util.Date())
+ reelHistory.todayCount.collect { count ->
+ database.screenTimeDao().syncReelsWatched(todayStr, count)
+ }
}
// Mount Content Blocker extension and configure delegates
@@ -406,6 +514,14 @@ fun FocusGramApp() {
filePickerHandler = filePickerHandler
)
+ sessionManager.session.permissionDelegate = FocusGramPermissionDelegate(
+ context = context,
+ onAndroidPermissionRequest = { perms, callback ->
+ pendingPermissionCallback = callback
+ permissionLauncher.launch(perms)
+ }
+ )
+
val initialUrl = when {
settings.minimalModeEnabled -> "https://www.instagram.com/direct/inbox/"
settings.startupPage == "following" -> "https://www.instagram.com/?variant=following"
@@ -446,87 +562,22 @@ fun FocusGramApp() {
previousReloadSettings = settings
}
- // 1. App Lock Enforcement Gate
- if (shouldShowLock) {
- AppLockScreen(
- encryptedStorage = encryptedStorage,
- onUnlockSuccess = { isAppUnlocked = true },
- isBiometricAllowed = settings.biometricEnabled
- )
- return
+ // 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()
}
- // 2. Time limit reached (Daily limit exhausted or Session exhausted)
- if ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) {
- val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60)
- val extensionsLeft = if (isDailyLimitExhausted) {
- if (settings.dailyLimitAdClaimedDate == today) 0 else 1
- } else {
- (3 - adExtensionsUsed).coerceAtLeast(0)
- }
- SessionExhaustedScreen(
- elapsedMinutes = elapsedMins,
- extensionsRemaining = extensionsLeft,
- onExit = { activity?.finish() },
- onWatchAdToExtend = {
- if (extensionsLeft > 0) {
- destination = AppDestination.ADSTERRA_REWARDED
- }
+ // Predictive back gesture: when at the root destination and cannot go back in WebView,
+ // disabling BackHandler allows Android's predictive back-to-home animation to trigger cleanly.
+ val canHandleBack = !isGateActive && (
+ destination != AppDestination.MAIN_WEBVIEW ||
+ sessionManager.canGoBack.value ||
+ sessionManager.currentUrl.value.lowercase().let {
+ it.contains("/direct") || (!it.endsWith("instagram.com/") && !it.endsWith("instagram.com"))
}
- )
- return
- }
+ )
- // 3. Cooldown Interval Gate between sessions
- if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) {
- CooldownGateScreen(
- remainingSecondsInitial = remainingCooldownSeconds,
- onCooldownExpired = { hasPassedCooldownGate = true },
- onExit = { activity?.finish() }
- )
- return
- }
-
- // 4. Mindfulness Breath Gate
- if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) {
- BreathGateScreen(
- durationSeconds = settings.breathGateSeconds,
- onFinish = { hasPassedBreathGate = true }
- )
- return
- }
-
- // 5. Effort Friction Challenge
- if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) {
- EffortFrictionGate(
- sessionType = "Instagram",
- remainingCreditMinutes = settings.instaMinutes,
- onProceed = { hasPassedEffortFriction = true },
- onWatchAd = {
- destination = AppDestination.ADSTERRA_REWARDED
- },
- onCancel = { activity?.finish() }
- )
- return
- }
-
- // 6. Cold-start Conscious Session Picker (Set your intention)
- if (!hasPassedSessionPicker) {
- AppSessionPicker(
- onSessionSelected = { minutes ->
- sessionDurationMinutes = minutes
- sessionRemainingSeconds = minutes * 60
- totalElapsedSeconds = 0
- adExtensionsUsed = 0
- isSessionExhausted = false
- hasPassedSessionPicker = true
- }
- )
- return
- }
-
- // Back button handling: going back from subpages ALWAYS takes to Settings main screen
- BackHandler(enabled = true) {
+ BackHandler(enabled = canHandleBack) {
when {
destination == AppDestination.APP_LOCK_SETUP -> {
destination = AppDestination.APP_LOCK_SETTINGS
@@ -558,12 +609,10 @@ fun FocusGramApp() {
}
else -> {
val currentUrlStr = sessionManager.currentUrl.value.lowercase()
- if (currentUrlStr.contains("/direct") || !currentUrlStr.endsWith("instagram.com/")) {
+ if (currentUrlStr.contains("/direct") || (!currentUrlStr.endsWith("instagram.com/") && !currentUrlStr.endsWith("instagram.com"))) {
isWebInputFocused = false
isModalActive = false
sessionManager.goBack()
- } else {
- activity?.finish()
}
}
}
@@ -610,20 +659,30 @@ fun FocusGramApp() {
)
)
- // Instagram Reels Native Top Header matching inspiration
+ // Instagram Reels Native Top Header with watched counter and media download
ReelsTopBar(
- isVisible = destination == AppDestination.MAIN_WEBVIEW &&
+ isVisible = !isGateActive &&
+ destination == AppDestination.MAIN_WEBVIEW &&
islandMode == IslandMode.FLOATING &&
!isModalActive &&
!isWebInputFocused,
+ watchedCount = reelsWatchedSessionCount,
+ isDownloadEnabled = settings.videoDownloadEnabled,
onReelsClick = {
sessionManager.refreshReels()
},
+ onDownloadClick = {
+ extensionManager.triggerReelDownload()
+ sessionManager.userScriptManager?.evaluate(
+ sessionManager.session,
+ "window.dispatchEvent(new CustomEvent('focusgram:download-reel'));"
+ )
+ },
modifier = Modifier.align(Alignment.TopCenter)
)
// Ambient background behind the floating nav bar on Reels (seamless gradient, no harsh border line)
- if (destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) {
+ if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW && islandMode == IslandMode.FLOATING && !isKeyboardOrInputActive) {
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
@@ -651,7 +710,7 @@ fun FocusGramApp() {
}
// Native Bottom Navigation Bar (OLED floating island)
- if (destination == AppDestination.MAIN_WEBVIEW) {
+ if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) {
val instagramTheme by sessionManager.instagramTheme.collectAsState()
NativeBottomNavBar(
currentUrl = currentUrl,
@@ -684,7 +743,7 @@ fun FocusGramApp() {
// Sub-screen Animated Overlay (Settings, Reels History, App Lock, etc.)
AnimatedVisibility(
- visible = destination != AppDestination.MAIN_WEBVIEW,
+ visible = !isGateActive && destination != AppDestination.MAIN_WEBVIEW,
enter = slideInHorizontally(animationSpec = tween(280)) { it } + fadeIn(animationSpec = tween(250)),
exit = slideOutHorizontally(animationSpec = tween(280)) { it } + fadeOut(animationSpec = tween(220))
) {
@@ -721,13 +780,14 @@ fun FocusGramApp() {
destination = AppDestination.MAIN_WEBVIEW
sessionManager.navigateTo(url)
},
- screenTimeDao = database.screenTimeDao()
+ screenTimeDao = database.screenTimeDao(),
+ reelHistory = reelHistory
)
}
AppDestination.REELS_HISTORY -> {
ReelsHistoryScreen(
- repository = reelsRepository,
+ reelHistory = reelHistory,
onBack = { destination = AppDestination.SETTINGS },
onOpenReel = { url ->
destination = AppDestination.MAIN_WEBVIEW
@@ -751,7 +811,8 @@ fun FocusGramApp() {
ScreenTimeScreen(
screenTimeDao = database.screenTimeDao(),
dailyLimitMinutes = settings.dailyLimitMinutes,
- onBack = { destination = AppDestination.SETTINGS }
+ onBack = { destination = AppDestination.SETTINGS },
+ reelHistory = reelHistory
)
}
@@ -822,6 +883,77 @@ fun FocusGramApp() {
}
}
}
+
+ // Full-screen Gate & Mindful Session Picker Overlays
+ // Rendered on top of GeckoView so Instagram preloads seamlessly in the background.
+ if (isGateActive) {
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(PureBlack)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null
+ ) {}
+ ) {
+ if (shouldShowLock) {
+ AppLockScreen(
+ encryptedStorage = encryptedStorage,
+ onUnlockSuccess = { isAppUnlocked = true },
+ isBiometricAllowed = settings.biometricEnabled
+ )
+ } else if ((isDailyLimitExhausted || isSessionExhausted) && destination != AppDestination.ADSTERRA_REWARDED) {
+ val elapsedMins = max(sessionDurationMinutes, (totalElapsedSeconds + 59) / 60)
+ val extensionsLeft = if (isDailyLimitExhausted) {
+ if (settings.dailyLimitAdClaimedDate == today) 0 else 1
+ } else {
+ (3 - adExtensionsUsed).coerceAtLeast(0)
+ }
+ SessionExhaustedScreen(
+ elapsedMinutes = elapsedMins,
+ extensionsRemaining = extensionsLeft,
+ onExit = { activity?.finish() },
+ onWatchAdToExtend = {
+ if (extensionsLeft > 0) {
+ destination = AppDestination.ADSTERRA_REWARDED
+ }
+ }
+ )
+ } else if (remainingCooldownSeconds > 0 && !hasPassedCooldownGate) {
+ CooldownGateScreen(
+ remainingSecondsInitial = remainingCooldownSeconds,
+ onCooldownExpired = { hasPassedCooldownGate = true },
+ onExit = { activity?.finish() }
+ )
+ } else if (settings.showBreathGate && settings.breathGateSeconds > 0 && !hasPassedBreathGate) {
+ BreathGateScreen(
+ durationSeconds = settings.breathGateSeconds,
+ onFinish = { hasPassedBreathGate = true }
+ )
+ } else if (settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) {
+ EffortFrictionGate(
+ sessionType = "Instagram",
+ remainingCreditMinutes = settings.instaMinutes,
+ onProceed = { hasPassedEffortFriction = true },
+ onWatchAd = {
+ destination = AppDestination.ADSTERRA_REWARDED
+ },
+ onCancel = { activity?.finish() }
+ )
+ } else if (!hasPassedSessionPicker) {
+ AppSessionPicker(
+ onSessionSelected = { minutes ->
+ sessionDurationMinutes = minutes
+ sessionRemainingSeconds = minutes * 60
+ totalElapsedSeconds = 0
+ adExtensionsUsed = 0
+ isSessionExhausted = false
+ hasPassedSessionPicker = true
+ }
+ )
+ }
+ }
+ }
}
}
}
diff --git a/Ported/app/src/main/res/drawable/ic_credit_downloader.png b/Ported/app/src/main/res/drawable/ic_credit_downloader.png
new file mode 100644
index 0000000..23328c0
Binary files /dev/null and b/Ported/app/src/main/res/drawable/ic_credit_downloader.png differ
diff --git a/Ported/app/src/main/res/drawable/ic_credit_geckoview.xml b/Ported/app/src/main/res/drawable/ic_credit_geckoview.xml
new file mode 100644
index 0000000..cf0d7f1
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_credit_geckoview.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/Ported/app/src/main/res/drawable/ic_credit_iconify.xml b/Ported/app/src/main/res/drawable/ic_credit_iconify.xml
new file mode 100644
index 0000000..c3ab318
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_credit_iconify.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/Ported/app/src/main/res/drawable/ic_credit_jetpack_compose.xml b/Ported/app/src/main/res/drawable/ic_credit_jetpack_compose.xml
new file mode 100644
index 0000000..b978a50
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_credit_jetpack_compose.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/Ported/app/src/main/res/drawable/ic_credit_sqlite.xml b/Ported/app/src/main/res/drawable/ic_credit_sqlite.xml
new file mode 100644
index 0000000..8a1e4e6
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_credit_sqlite.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
diff --git a/Ported/app/src/main/res/drawable/ic_credit_ublock.xml b/Ported/app/src/main/res/drawable/ic_credit_ublock.xml
new file mode 100644
index 0000000..df32aca
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_credit_ublock.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
diff --git a/Ported/app/src/main/res/drawable/ic_download_fill.xml b/Ported/app/src/main/res/drawable/ic_download_fill.xml
new file mode 100644
index 0000000..d50f184
--- /dev/null
+++ b/Ported/app/src/main/res/drawable/ic_download_fill.xml
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt b/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt
index 802e0ec..5f9dd61 100644
--- a/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt
+++ b/Ported/app/src/test/kotlin/com/ujwal/focusgram/ReelsHistoryLogicTest.kt
@@ -64,4 +64,56 @@ class ReelsHistoryLogicTest {
val averagePerDay = recentCount / 7.0
assertEquals(2.0, averagePerDay, 0.001)
}
+
+ @Test
+ fun testReelHistoryOperations() {
+ val tempFile = java.io.File.createTempFile("reel_test", ".json")
+ try {
+ val history = com.ujwal.focusgram.feature.history.ReelHistory(tempFile)
+ assertEquals(0, history.sessionCount.value)
+ assertEquals(0, history.items.value.size)
+
+ // Watch reel 1
+ history.onWatched("abc12345", "photographer", "Nice view")
+ assertEquals(1, history.sessionCount.value)
+ assertEquals(1, history.items.value.size)
+ assertEquals("abc12345", history.items.value[0].code)
+ assertEquals("photographer", history.items.value[0].author)
+ assertEquals("Nice view", history.items.value[0].caption)
+ assertEquals(1, history.items.value[0].views)
+
+ // Watch reel 1 again (views increment, session count remains 1 unique)
+ history.onWatched("abc12345", "", "")
+ assertEquals(1, history.sessionCount.value)
+ assertEquals(1, history.items.value.size)
+ assertEquals(2, history.items.value[0].views)
+ assertEquals("photographer", history.items.value[0].author)
+
+ // Meta update
+ history.onMeta("abc12345", "photographer_pro", "Updated caption")
+ assertEquals("photographer_pro", history.items.value[0].author)
+ assertEquals("Updated caption", history.items.value[0].caption)
+
+ // Watch reel 2
+ history.onWatched("def67890", "artist", "Artwork")
+ assertEquals(2, history.sessionCount.value)
+ assertEquals(2, history.items.value.size)
+
+ // Delete reel 1
+ history.delete("abc12345")
+ assertEquals(1, history.items.value.size)
+ assertEquals("def67890", history.items.value[0].code)
+
+ // Clear history
+ history.clear()
+ assertEquals(0, history.items.value.size)
+ assertEquals(2, history.sessionCount.value)
+
+ // Reset session
+ history.resetSession()
+ assertEquals(0, history.sessionCount.value)
+ } finally {
+ tempFile.delete()
+ }
+ }
}
diff --git a/bottom-icons/download--icon-fill.svg b/bottom-icons/download--icon-fill.svg
new file mode 100644
index 0000000..41e3b59
--- /dev/null
+++ b/bottom-icons/download--icon-fill.svg
@@ -0,0 +1,7 @@
+