Port(76% Done):

# Problem Fixes -

> Fixed Captions and audio banner being cutted off when adblocker is off.
> Fixed a bug due to which feed posts were getting blanked out after ~13 posts (due to too tight adblocking)
> Fixed Performance bottlenecks
> Fixed "Block Reels Swiping in DMs" not working properly
   and more...

# New -
> Access watched Reels History easily from settings.
> Reels counter [per session(In reels page) and daily(in settings)]
> Smoothened Reels scrolling
> Increased transparency of bottom nav bar
and few more...

# Known Problems:

> "Download Media" feature doesn't work.
> You might see occasional ads (in reels player only)
> File Picker doesn't open (hence no picture in DMs, No Posts, No Storied etc).
> Clearing reels hitory clears "Reels Watched" too. (could be a feature but i dont want the "Reels Watched" metric to be cleared/changed)
> Hide Likes Count doesnt work [ hides like icon instead ;) ]
> Homepage & Reels page might ocassionally go blank while scrolling (ad remover sometimes removes whole CSS Container if some keywords match the list of ad trigger)
> You can skip cooldowns (intentional, for development testing)

there can be more bugs as v3 is incomplete.
This commit is contained in:
Ujwal223 committed 2026-10-14 23:18:03 +05:45
1 parent b709693e1d
commit fb2a367c83
44 files changed
+3499 -487

No files matched your search

+2
View File
@@ -110,5 +110,7 @@ PRD.md
system/
Documents/
Ghostify/
Ghostify
inspo/
Instagram-Downloader/
+4
View File
@@ -29,3 +29,7 @@ key.properties
Thumbs.db
*.log
*.hprof
# Others
Ghostify/
Instagram-Downloader/
+13
View File
@@ -11,6 +11,19 @@
<uses-permission android:name="android.permission.USE_FINGERPRINT" />
<uses-permission android:name="android.permission.VIBRATE" />
<!-- Media and Storage Permissions for file uploads (DMs, Posts, Stories) and downloads -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<application
android:name=".FocusGramApp"
android:allowBackup="false"
@@ -25,7 +25,8 @@ let cachedSettings = {
ghostHideStorySeen: true,
ghostHideDirectSeen: true,
ghostHideTypingIndicator: true,
unblurMode: 'tap'
unblurMode: 'tap',
videoDownloadEnabled: true
};
const BLOCKED_URL_PATTERNS = [
@@ -33,9 +34,15 @@ const BLOCKED_URL_PATTERNS = [
"*://*.instagram.com/ajax/bz*",
"*://graph.instagram.com/*",
"*://*.instagram.com/api/v1/ads/*",
"*://*.instagram.com/api/v1/ad_*",
"*://*.instagram.com/api/v1/feed/saved/ads*",
"*://*.instagram.com/api/v1/feed/reels_tray/ads*",
"*://*.instagram.com/api/v1/stories/tray/ads*",
"*://*.instagram.com/api/v1/stories/ads*",
"*://*.instagram.com/api/v1/feed/timeline/ads*",
"*://*.instagram.com/api/v1/clips/home/ads*",
"*://*.instagram.com/api/v1/clips/ads*",
"*://*.instagram.com/api/v1/clips/stream/ads*",
"*://*.facebook.com/tr/*",
"*://*.facebook.com/tr?*",
"*://*.facebook.net/*",
@@ -45,14 +52,7 @@ const BLOCKED_URL_PATTERNS = [
"*://*.googleadservices.com/*",
"*://*.googlesyndication.com/*",
"*://*.adservice.google.*/*",
"*://*.cdninstagram.com/v/t51.2885-15/e35/*_ad*",
"*://*.instagram.com/api/v1/clips/home/ads*",
"*://*.instagram.com/api/v1/clips/ads*",
"*://*.instagram.com/api/v1/clips/stream/ads*",
"*://*.instagram.com/api/v1/ads/*",
"*://*.instagram.com/api/v1/feed/saved/ads*",
"*://*.instagram.com/api/v1/stories/tray/ads*",
"*://*.instagram.com/api/v1/feed/timeline/ads*"
"*://*.cdninstagram.com/v/t51.2885-15/e35/*_ad*"
];
const GHOST_BLOCKED_URL_PATTERNS = [
@@ -168,15 +168,17 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
if (nativePort) {
try {
if (message.type === 'ReelMetadata' || message.action === 'reelViewed') {
if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
const payload = message.payload || message.data || message.reel || message;
nativePort.postMessage({
action: 'reelViewed',
type: 'reelViewed',
url: payload.url || '',
title: payload.title || 'Instagram Reel',
author: payload.author || 'Instagram Creator',
thumbnailUrl: payload.thumbnailUrl || ''
type: message.type || 'reelViewed',
code: payload.code || message.code || '',
url: payload.url || message.url || '',
title: payload.caption || payload.title || message.caption || message.title || '',
caption: payload.caption || message.caption || '',
author: payload.author || message.author || '',
thumbnailUrl: payload.thumbnailUrl || message.thumbnailUrl || ''
});
} else {
nativePort.postMessage(message);
@@ -17,13 +17,17 @@
div[data-testid="post_ad"],
div[aria-label*="Sponsored" i]:not(main):not([role="feed"]),
div[aria-label*="sponsored" i]:not(main):not([role="feed"]),
div[aria-label="Ad" i]:not(main):not([role="feed"]),
article:has([aria-label="Ad" i]),
article:has([aria-label*="Sponsored" i]),
article:has(a[href*="/ads/"]),
article:has(a[href*="/about/ads"]),
article:has(a[href*="/ad_"]),
article:has(a[href*="ads.instagram.com"]),
article[data-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;
@@ -31,6 +35,10 @@
padding: 0 !important;
border: none !important;
}
div[data-fg-reel-ad="1"] {
visibility: hidden !important;
pointer-events: none !important;
}
a[href*="/ads/"],
a[href*="ads.instagram.com"] {
display: none !important;
@@ -59,12 +67,12 @@
const SPONSORED_KEYWORDS = [
'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise',
'publicidad', 'patrocinado', 'sponsorizzato', 'sponsrad',
'paid partnership'
'paid partnership', 'werbung', 'anuncio', 'werbeanzeige', 'publicité'
];
const AD_CTA_KEYWORDS = [
'learn more', 'shop now', 'install now', 'sign up', 'apply now',
'book now', 'download', 'order now', 'get offer', 'subscribe'
const STRICT_AD_CTAS = [
'shop now', 'install now', 'sign up', 'apply now',
'book now', 'order now', 'get offer'
];
function filterDynamicSponsoredPosts() {
@@ -77,31 +85,34 @@
let isAd = false;
// Check header text (Sponsored, Paid partnership)
const spans = article.querySelectorAll('header span, header a, span[dir="auto"], [role="button"] span');
for (let j = 0; j < spans.length; j++) {
const text = (spans[j].textContent || '').trim().toLowerCase();
if (SPONSORED_KEYWORDS.some(kw => 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;
File diff suppressed because it is too large. Load diff
@@ -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",
@@ -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 (_) {}
});
@@ -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"
}]
}
@@ -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 <meta> 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);
@@ -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 || '';
@@ -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);
}
};
})();
@@ -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;
}
@@ -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) {}
}
}
@@ -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)
}
@@ -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()
)
@@ -17,7 +17,7 @@ import androidx.room.RoomDatabase
SessionRecord::class,
SnapshotItem::class
],
version = 2,
version = 3,
exportSchema = false
)
abstract class FocusGramDatabase : RoomDatabase() {
@@ -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,
@@ -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"
@@ -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<String>, (Boolean) -> Unit) -> Unit)? = null
) : GeckoSession.PermissionDelegate {
override fun onAndroidPermissionsRequest(
session: GeckoSession,
permissions: Array<out String>?,
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<Int> {
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<out GeckoSession.PermissionDelegate.MediaSource>?,
audio: Array<out GeckoSession.PermissionDelegate.MediaSource>?,
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"
}
}
@@ -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<GeckoSession.PromptDelegate.PromptResponse>()
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())
}
}
@@ -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 {
@@ -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")
@@ -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,
@@ -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 <T> 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) {}
}
@@ -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))
@@ -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)
}
}
}
}
@@ -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<String>()
private var entries: List<ReelEntry> = 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<ReelEntry>): Int {
val start = getTodayStartMillis()
return list.count { it.lastSeen >= start }
}
private val _items = MutableStateFlow(entries)
val items: StateFlow<List<ReelEntry>> = _items
private val _sessionCount = MutableStateFlow(0)
val sessionCount: StateFlow<Int> = _sessionCount // unique reels since app start
private val _todayCount = MutableStateFlow(calculateTodayCount(entries))
val todayCount: StateFlow<Int> = _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<ReelEntry>) {
entries = list.take(500)
_items.value = entries
_todayCount.value = calculateTodayCount(entries)
val snapshot = entries
io.execute { save(snapshot) }
}
private fun save(list: List<ReelEntry>) {
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<ReelEntry> = 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()
}
}
@@ -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<Any>? {
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
}
}
}
@@ -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) {
@@ -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"
)
@@ -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))
}
}
}
}
@@ -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<Long?>(null) }
var customEndDateMillis by remember { mutableStateOf<Long?>(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<String>()
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()
@@ -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(
@@ -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<Uri>) -> Unit)?>(null) }
val multiFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments()
) { uris: List<Uri> ->
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val uris = mutableListOf<Uri>()
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<String>) -> Unit)? = null
var launchSingle: ((Array<String>) -> Unit)? = null
var launchChooser: ((Array<String>, Boolean) -> Unit)? = null
override fun onPickFiles(
mimeTypes: Array<String>,
@@ -207,13 +235,8 @@ fun FocusGramApp() {
onResult: (List<Uri>) -> 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
}
)
}
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="1560"
android:viewportHeight="1560">
<path
android:fillColor="#FF008787"
android:pathData="M778,56 A724,724 0 1,0 778,1504 A724,724 0 1,0 778,56 Z"/>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M1083,674a104,104 0 0,0 -104,104c0,13 2,24 6,34L821,977V639a104,104 0 1,0 -84,0v338L573,812c3,-10 6,-22 6,-33a104,104 0 1,0 -63,95l233,233a42,42 0 0,0 60,0l233,-233a104,104 0 1,0 41,-200"/>
</vector>
@@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group
android:pivotX="12"
android:pivotY="12"
android:scaleX="0.85"
android:scaleY="0.85">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M2.8571,19.4286V9.6423c0.232,-0.06 0.4583,-0.1577 0.6703,-0.2949L5.143,8.3023v9.9834h4.5714v2.2857H4c-0.6309,0 -1.1429,-0.512 -1.1429,-1.1428Zm7.4286,-8.5714c0,-0.9463 0.768,-1.7143 1.7143,-1.7143 0.9463,0 1.7143,0.768 1.7143,1.7143 0,0.9511 -0.7751,1.7142 -1.7143,1.7142 -0.9392,0 -1.7143,-0.7631 -1.7143,-1.7142zm-5.1428,-8V1.1429C5.1429,0.512 5.6549,0 6.2857,0c0.6309,0 1.1429,0.512 1.1429,1.1429v0.2354zm9.1428,15.4285h4.5715V8.3023l1.6154,1.0451c0.212,0.1372 0.4383,0.2349 0.6703,0.2949v9.7863c0,0.6308 -0.512,1.1429 -1.1429,1.1429h-5.7143Zm-0.0034,-17.0274 8.0531,5.2109c0.5292,0.3428 0.6812,1.0508 0.3383,1.5805 -0.3428,0.5292 -1.0508,0.6812 -1.5805,0.3383L13.052,3.1846l0.1897,-0.1229c0.64,-0.4143 1.004,-1.0954 1.0406,-1.8034ZM11.3789,0.1834c0.5297,-0.3428 1.2377,-0.1908 1.5805,0.3383 0.3429,0.5297 0.1909,1.2377 -0.3383,1.5806L2.907,8.388c-0.5298,0.3429 -1.2378,0.1909 -1.5806,-0.3383 -0.3429,-0.5297 -0.1909,-1.2377 0.3383,-1.5805Zm2.6005,22.1023H22c0.4732,0 0.8572,0.384 0.8572,0.8572A0.8575,0.8575 0 0,1 22,24H2a0.8575,0.8575 0 0,1 -0.8571,-0.8571c0,-0.4732 0.384,-0.8572 0.8571,-0.8572h8.0206c0.3954,0.6829 1.1343,1.1429 1.9794,1.1429s1.584,-0.46 1.9794,-1.1429zm-0.8365,-8.8097v7.6669c0,0.6308 -0.512,1.1428 -1.1429,1.1428s-1.1429,-0.512 -1.1429,-1.1428V13.476a2.8453,2.8453 0 0,0 1.143,0.2383c0.4062,0 0.7925,-0.0851 1.1428,-0.2383z"/>
</group>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF4285F4"
android:pathData="M12.135,0.003a2.577,2.577 0 0,0 -1.42,0.344L2.55,5.06a2.577,2.577 0 0,0 -0.953,0.967l3.957,2.17a1.765,1.765 0 0,1 0.603,-0.588l4.951,-2.86a1.761,1.761 0 0,1 1.76,0l4.951,2.86a1.753,1.753 0 0,1 0.526,0.46l3.921,-2.265a2.577,2.577 0 0,0 -0.816,-0.744L13.291,0.347a2.577,2.577 0 0,0 -1.152,-0.34Zm-0.145,4.912a1.36,1.36 0 0,0 -0.68,0.182L6.36,7.953a1.345,1.345 0 0,0 -0.454,0.435l1.92,1.055a1.05,1.05 0 0,1 0.31,-0.275l3.34,-1.928a1.054,1.054 0 0,1 1.051,0l3.336,1.928a1.066,1.066 0 0,1 0.238,0.199l0.018,-0.01 -0.002,-0.002 1.877,-1.08a1.353,1.353 0 0,0 -0.373,-0.322l-4.95,-2.86a1.357,1.357 0 0,0 -0.68,-0.178Zm10.432,1.149 -3.92,2.26a1.757,1.757 0 0,1 0.2,0.812v5.715c0,0.627 -0.332,1.21 -0.88,1.528l-4.95,2.86a1.761,1.761 0 0,1 -0.64,0.214v4.524a2.577,2.577 0 0,0 1.059,-0.313l8.162,-4.713a2.577,2.577 0 0,0 1.289,-2.23V7.297a2.577,2.577 0 0,0 -0.318,-1.228Zm-20.937,0.238a2.577,2.577 0 0,0 -0.227,0.99v9.43c0,0.918 0.491,1.767 1.293,2.229l8.162,4.713a2.57,2.57 0 0,0 1.225,0.336v-4.531a1.761,1.761 0 0,1 -0.833,-0.235L6.156,16.38a1.765,1.765 0 0,1 -0.877,-1.528V9.136a1.776,1.776 0 0,1 0.131,-0.676Zm16.67,2.223 -1.89,1.092a1.058,1.058 0 0,1 0.124,0.46v3.856c0,0.378 -0.2,0.72 -0.526,0.907l-3.336,1.93a1.04,1.04 0 0,1 -0.295,0.107v2.17c0.152,-0.027 0.304,-0.078 0.44,-0.157l4.949,-2.859a1.36,1.36 0 0,0 0.68,-1.18V9.136a1.36,1.36 0 0,0 -0.147,-0.607Zm-12.387,0.13a1.36,1.36 0 0,0 -0.088,0.481v5.715a1.36,1.36 0 0,0 0.68,1.18l4.95,2.86c0.194,0.112 0.412,0.166 0.627,0.177v-2.174a1.054,1.054 0 0,1 -0.46,-0.123L8.14,14.843a1.054,1.054 0 0,1 -0.532,-0.908v-3.853a0.99,0.99 0 0,1 0.077,-0.38l-0.034,-0.013z"/>
</vector>
@@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="128"
android:viewportHeight="128">
<!-- Blue SQLite block -->
<path
android:fillColor="#FF0F80CC"
android:pathData="M16,8 H112 A8,8 0 0,1 120,16 V112 A8,8 0 0,1 112,120 H16 A8,8 0 0,1 8,112 V16 A8,8 0 0,1 16,8 Z"/>
<!-- High-contrast White Feather -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M115.95,2.781c-5.5,-4.906 -12.164,-2.933 -18.734,2.899a44.347,44.347 0 0,0 -2.914,2.859c-11.25,11.926 -21.684,34.023 -24.926,50.895 1.262,2.563 2.25,5.832 2.894,8.328 0.168,0.64 0.32,1.242 0.442,1.754 0.285,1.207 0.437,1.996 0.437,1.996s-0.101,-0.383 -0.515,-1.582c-0.078,-0.23 -0.168,-0.484 -0.27,-0.773 -0.043,-0.125 -0.105,-0.274 -0.172,-0.434 -0.734,-1.703 -2.765,-5.305 -3.656,-6.867 -0.762,2.25 -1.437,4.36 -2.004,6.265 2.578,4.715 4.149,12.797 4.149,12.797s-0.137,-0.523 -0.782,-2.347c-0.578,-1.621 -3.441,-6.64 -4.117,-7.809 -1.164,4.281 -1.625,7.172 -1.207,7.88 0.809,1.362 1.574,3.722 2.25,6.323 1.524,5.867 2.586,13.012 2.586,13.012s0.031,0.469 0.094,1.2a118.653,118.653 0 0,0 0.297,14.651c0.504,6.11 1.453,11.363 2.664,14.172l0.828,-0.449c-1.781,-5.535 -2.504,-12.793 -2.188,-21.156 0.48,-12.793 3.422,-28.215 8.856,-44.289 9.191,-24.27 21.938,-43.738 33.602,-53.035 -10.633,9.602 -25.023,40.684 -29.332,52.195 -4.82,12.891 -8.238,24.984 -10.301,36.574 3.55,-10.863 15.047,-15.53 15.047,-15.53s5.637,-6.958 12.227,-16.888c-3.95,0.903 -10.43,2.442 -12.598,3.352 -3.2,1.344 -4.067,1.8 -4.067,1.8s10.371,-6.312 19.27,-9.171c12.234,-19.27 25.562,-46.648 12.141,-58.621"/>
</vector>
@@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<!-- Crisp White Shield Background -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16,1 C10.2,4.8 8.2,4.8 2.5,4.8 C2.5,23.5 2.5,23.5 16,31 C29.5,23.5 29.5,23.5 29.5,4.8 C23.8,4.8 21.8,4.8 16,1 Z"/>
<!-- uBlock Stylized 'u' and emblem in deep red -->
<path
android:fillColor="#FF800000"
android:pathData="M7.943,9.964h2.016v6.047c0,1.76 0.249,2.011 2.015,2.011 1.767,0 2.016,-0.256 2.016,-2.016v-6.041h2.011v1.020c0.869,-0.661 1.932,-1.020 3.027,-1.020 2.776,0.009 5.025,2.26 5.031,5.041 -0.011,2.776 -2.26,5.025 -5.036,5.031 -1.349,-0.005 -2.641,-0.547 -3.584,-1.509 -0.615,1.025 -1.749,1.509 -3.464,1.509 -2.771,0 -4.031,-1.26 -4.031,-4.031z"/>
<path
android:fillColor="#FF800000"
android:pathData="M19.021,11.979c-1.677,-0.009 -3.032,1.349 -3.021,3.027 -0.005,1.672 1.355,3.025 3.027,3.016 1.671,0.009 3.025,-1.349 3.015,-3.027 0.011,-1.672 -1.348,-3.025 -3.020,-3.016z"/>
</vector>
@@ -0,0 +1,26 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Bottom horizontal bar -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M5,18h14a1,1 0 0,1 1,1v0a1,1 0 0,1 -1,1h-14a1,1 0 0,1 -1,-1v0a1,1 0 0,1 1,-1z" />
<!-- Left vertical edge -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M4,15h2a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1h-2a1,1 0 0,1 -1,-1v-3a1,1 0 0,1 1,-1z" />
<!-- Right vertical edge -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M18,15h2a1,1 0 0,1 1,1v3a1,1 0 0,1 -1,1h-2a1,1 0 0,1 -1,-1v-3a1,1 0 0,1 1,-1z" />
<!-- Down arrow head -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,15a1,1 0 0,1 -0.58,-0.18l-4,-2.82a1,1 0 0,1 -0.24,-1.39a1,1 0 0,1 1.4,-0.24L12,12.76l3.4,-2.56a1,1 0 0,1 1.2,1.6l-4,3a1,1 0 0,1 -0.6,0.2z" />
<!-- Arrow shaft -->
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,13a1,1 0 0,1 -1,-1V4a1,1 0 0,1 2,0v8a1,1 0 0,1 -1,1z" />
</vector>
@@ -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()
}
}
}
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect width="16" height="2" x="4" y="18" fill="currentColor" rx="1" ry="1" />
<rect width="4" height="2" x="3" y="17" fill="currentColor" rx="1" ry="1" transform="rotate(-90 5 18)" />
<rect width="4" height="2" x="17" y="17" fill="currentColor" rx="1" ry="1" transform="rotate(-90 19 18)" />
<path fill="currentColor" d="M12 15a1 1 0 0 1-.58-.18l-4-2.82a1 1 0 0 1-.24-1.39a1 1 0 0 1 1.4-.24L12 12.76l3.4-2.56a1 1 0 0 1 1.2 1.6l-4 3a1 1 0 0 1-.6.2" />
<path fill="currentColor" d="M12 13a1 1 0 0 1-1-1V4a1 1 0 0 1 2 0v8a1 1 0 0 1-1 1" />
</svg>

After

Width:  |  Height:  |  Size: 610 B