Port(82% Done):

# Problem Fixes -

> "Download Media" feature works completely for all media types (Posts, Stories, Reels)
> Fixed a bug due to which file Picker wasn't opening
> Fixed Performance bottlenecks
> Fixed "Block Reels Swiping in DMs" not working properly
   and more...

 # New -
> Lock App in potrait mode.
> New dowload animations and UI.
> Bottom nav bar has got that PREMIUM look now.
and few more...

 # Known Problems:

> Hide Likes Count doesnt work.

there can be more bugs as v3 is incomplete.

NOW COMES TOTALLY NEW FEATURES IN NEXT COMMITS ;)
This commit is contained in:
Ujwal223 committed 2026-10-16 10:00:35 +05:45
1 parent fb2a367c83
commit 9b00435d72
36 files changed
+3603 -732

No files matched your search

+3
View File
@@ -126,6 +126,7 @@ dependencies {
// Security & Biometric
implementation(libs.androidx.security.crypto)
implementation(libs.androidx.biometric)
implementation(libs.androidx.fragment.ktx)
// Coroutines
implementation(libs.kotlinx.coroutines.core)
@@ -158,6 +159,8 @@ configurations.all {
"androidx.lifecycle:lifecycle-common-java8:2.8.7",
"androidx.core:core:1.15.0",
"androidx.core:core-ktx:1.15.0",
"androidx.fragment:fragment:1.8.6",
"androidx.fragment:fragment-ktx:1.8.6",
"org.jetbrains.kotlin:kotlin-stdlib:2.0.21",
"org.jetbrains.kotlin:kotlin-stdlib-jdk8:2.0.21",
"org.jetbrains.kotlin:kotlin-stdlib-jdk7:2.0.21",
+39 -3
View File
@@ -3,18 +3,15 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<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" />
@@ -24,6 +21,33 @@
<uses-feature android:name="android.hardware.camera" android:required="false" />
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
<!-- Package Visibility Queries for Android 11+ (API 30+) Intent Resolution -->
<queries>
<!-- System Document and Media Pickers -->
<intent>
<action android:name="android.intent.action.GET_CONTENT" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.intent.action.PICK" />
<data android:mimeType="*/*" />
</intent>
<intent>
<action android:name="android.provider.action.PICK_IMAGES" />
</intent>
<!-- Camera capture intents -->
<intent>
<action android:name="android.media.action.IMAGE_CAPTURE" />
</intent>
<intent>
<action android:name="android.media.action.VIDEO_CAPTURE" />
</intent>
</queries>
<application
android:name=".FocusGramApp"
android:allowBackup="false"
@@ -43,6 +67,7 @@
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -80,6 +105,17 @@
android:value="FocusGram Digital Wellness Session &amp; Screen Time Tracker" />
</service>
<!-- FileProvider for temporary camera captures (Story, Post, DMs, Profile) -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -26,9 +26,11 @@ let cachedSettings = {
ghostHideDirectSeen: true,
ghostHideTypingIndicator: true,
unblurMode: 'tap',
videoDownloadEnabled: true
videoDownloadEnabled: false
};
let userIsAuthenticated = false;
const BLOCKED_URL_PATTERNS = [
"*://*.instagram.com/logging_client_events*",
"*://*.instagram.com/ajax/bz*",
@@ -66,8 +68,15 @@ const GHOST_BLOCKED_URL_PATTERNS = [
if (typeof browser !== 'undefined' && browser.webRequest && browser.webRequest.onBeforeRequest) {
browser.webRequest.onBeforeRequest.addListener(
function (details) {
if (cachedSettings.noAds === false) {
// User turned ad blocker off: do not cancel requests
if (cachedSettings.noAds === false || !userIsAuthenticated) {
// User turned ad blocker off or user is not logged in: do not cancel requests
return { cancel: false };
}
const url = (details.url || '').toLowerCase();
// NEVER block anything on or related to authentication, login, signup, or security checks
if (url.includes('/accounts/') || url.includes('/login') || url.includes('/signup') ||
url.includes('facebook.net') || url.includes('facebook.com') || url.includes('/ajax/bz') ||
url.includes('/api/v1/web/accounts/') || url.includes('/api/v1/accounts/')) {
return { cancel: false };
}
console.log('[Content Blocker] Intercepted and blocked network ad request:', details.url);
@@ -157,6 +166,10 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message) return;
if (message.action === 'authStateChanged') {
userIsAuthenticated = !!message.isAuthenticated;
}
if (message.action === 'getSettings') {
sendResponse({ settings: cachedSettings });
return true;
@@ -11,23 +11,40 @@
const COSMETIC_STYLE_ID = 'focusgram-cosmetic-filters';
let adBlockingEnabled = true;
function isAuthPage() {
try {
const path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) {
return true;
}
const cookies = document.cookie || '';
const hasAuthCookie = cookies.includes('sessionid=') || cookies.includes('ds_user_id=');
if ((path === '/' || path === '') && !hasAuthCookie) {
return true;
}
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) {
return true;
}
} catch (_) {}
return false;
}
if (isAuthPage()) {
const existing = document.getElementById(COSMETIC_STYLE_ID);
if (existing) existing.remove();
return; // NEVER run on auth, login, or signup pages
}
const CSS_RULES = `
/* Hide sponsored feed articles and containers without breaking Infinite Scroll IntersectionObserver */
/* Hide sponsored feed articles without breaking Infinite Scroll IntersectionObserver or feed containers */
div[data-ad-preview],
div[data-testid="post_ad"],
div[aria-label*="Sponsored" i]:not(main):not([role="feed"]),
div[aria-label*="sponsored" i]:not(main):not([role="feed"]),
div[aria-label="Ad" i]:not(main):not([role="feed"]),
article:has([aria-label="Ad" i]),
article:has([aria-label*="Sponsored" i]),
article:has(a[href*="/ads/"]),
article:has(a[href*="/ad_"]),
article:has(a[href*="ads.instagram.com"]),
article[data-ad-filtered="true"] {
display: none !important;
height: 0px !important;
min-height: 0px !important;
max-height: 0px !important;
height: 1px !important;
min-height: 1px !important;
max-height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
@@ -71,7 +88,7 @@
];
const STRICT_AD_CTAS = [
'shop now', 'install now', 'sign up', 'apply now',
'shop now', 'install now', 'apply now',
'book now', 'order now', 'get offer'
];
@@ -127,7 +144,6 @@
article.style.setProperty('margin', '0', 'important');
article.style.setProperty('padding', '0', 'important');
article.dataset.adFiltered = 'true';
window.dispatchEvent(new Event('scroll'));
}
}
File diff suppressed because it is too large. Load diff
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "FocusGram Content Engine",
"version": "1.61.0",
"version": "2.1.0",
"description": "Privacy-preserving network-level content blocking, stealth protection, and cosmetic filtering for FocusGram.",
"author": "FocusGram Project",
"homepage_url": "https://github.com/Ujwal223/FocusGram",
@@ -8,6 +8,19 @@
'use strict';
(function () {
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
} catch (_) {}
return false;
}
if (isAuthPage()) return; // Leave login/signup completely untouched
if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true;
@@ -13,6 +13,23 @@
(function () {
const STYLE_ID = 'focusgram-dynamic-guardrails-styles';
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
} catch (_) {}
return false;
}
if (isAuthPage()) {
var existing = document.getElementById(STYLE_ID);
if (existing) existing.remove();
return; // Leave login/signup completely untouched
}
// 1. Client-Side SPA Navigation Helper
window.__focusgramNavigate = function (path) {
if (!path) return;
@@ -286,16 +303,13 @@
if (config.noAds !== false) {
css += `
article:has(a[href*="/ad_"]),
article:has(a[href*="/ads/"]),
article:has(a[href*="ads.instagram.com"]),
article:has([aria-label="Ad" i]),
article:has([aria-label*="Sponsored" i]),
article[data-fg-ad-filtered="true"] {
display: none !important;
height: 0px !important;
min-height: 0px !important;
max-height: 0px !important;
height: 1px !important;
min-height: 1px !important;
max-height: 1px !important;
overflow: hidden !important;
opacity: 0 !important;
pointer-events: none !important;
@@ -309,8 +323,7 @@
}
div[data-ad-preview],
div[data-testid="post_ad"],
div[data-testid="suggested-users"],
div[aria-label="Ad" i]:not(main):not([role="feed"]) {
div[data-testid="suggested-users"] {
display: none !important;
}
`;
@@ -373,7 +386,6 @@
article.style.setProperty('margin', '0', 'important');
article.style.setProperty('padding', '0', 'important');
article.dataset.fgAdFiltered = 'true';
window.dispatchEvent(new Event('scroll'));
}
});
@@ -12,6 +12,19 @@
'use strict';
(function () {
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
} catch (_) {}
return false;
}
if (isAuthPage()) return; // Leave login/signup completely untouched
if (window.__fgFetchInterceptorInstalled) return;
window.__fgFetchInterceptorInstalled = true;
@@ -27,7 +40,7 @@
const AD_KEYWORDS = [
'sponsored', 'gesponsert', 'sponsorisé', 'sponsorise', 'publicidad',
'patrocinado', 'sponsorizzato', 'sponsrad', 'paid partnership', 'werbung',
'anuncio', 'werbeanzeige', 'publicité', 'ad_id', 'ad_tracking', 'sponsor_tags'
'anuncio', 'werbeanzeige', 'publicité'
];
function textHasAdSignal(value) {
@@ -39,69 +52,55 @@
function isAdNode(node) {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const adText = JSON.stringify({
organic_tracking_token: node.organic_tracking_token,
sponsor_tags: node.sponsor_tags,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label,
overlay_text: node.overlay_text
});
if (node.is_ad || node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) {
return true;
}
if (node.ad_id || node.ad_action_link || node.ad_impression_token || node.ad_metadata) {
return true;
}
if (node.commerciality_status === 'commercial' || (node.product_type && node.product_type === 'ad')) {
return true;
}
if (node.ad_header_style && node.ad_header_style !== 'none') {
return true;
}
if (typename === 'GraphAdStory' || typename.includes('Ad')) {
return true;
}
return !!(
node.is_ad ||
node.is_paid_partnership ||
(node.sponsor_tags && node.sponsor_tags.length > 0) ||
node.ad_tracking_token ||
node.ad_action_link ||
node.ad_id ||
node.ad_impression_token ||
node.ad_metadata ||
node.commerciality_status === 'commercial' ||
(node.product_type && node.product_type === 'ad') ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
typename === 'GraphAdStory' ||
typename.includes('Ad') ||
textHasAdSignal(adText)
);
const values = [node.social_context, node.title, node.header, node.label, node.overlay_text]
.filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ');
return textHasAdSignal(values);
}
function isSponsoredNode(node) {
if (!node || typeof node !== 'object') return false;
return !!(
node.is_paid_partnership ||
(node.sponsor_tags && node.sponsor_tags.length > 0) ||
(node.ad_action_link && node.ad_action_link.href) ||
(node.ad_header_style && node.ad_header_style !== 'none') ||
textHasAdSignal(JSON.stringify(node.social_context || node.header || node.label || ''))
);
if (node.is_paid_partnership || (Array.isArray(node.sponsor_tags) && node.sponsor_tags.length > 0)) {
return true;
}
if (node.ad_action_link && node.ad_action_link.href) {
return true;
}
if (node.ad_header_style && node.ad_header_style !== 'none') {
return true;
}
const text = [node.social_context, node.header, node.label].filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ');
return textHasAdSignal(text);
}
function isSuggestedNode(node) {
if (!node || typeof node !== 'object') return false;
const typename = String(node.__typename || '');
const reason = JSON.stringify({
reason: node.suggested_reason,
social_context: node.social_context,
title: node.title,
header: node.header,
label: node.label
}).toLowerCase();
return !!(
node.is_suggested ||
node.is_suggested_for_you ||
node.is_recommendation ||
node.suggested_users ||
node.suggested_media ||
node.suggested_content ||
node.recommendation_source ||
typename.includes('Suggested') ||
typename.includes('Recommendation') ||
reason.includes('suggested') ||
reason.includes('recommend')
);
if (node.is_suggested || node.is_suggested_for_you || node.is_recommendation ||
node.suggested_users || node.suggested_media || node.suggested_content ||
node.recommendation_source || typename.includes('Suggested') || typename.includes('Recommendation')
) {
return true;
}
const text = [node.suggested_reason, node.social_context, node.title, node.header, node.label]
.filter(Boolean).map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(' ').toLowerCase();
return text.includes('suggested') || text.includes('recommend');
}
function shouldFilterNode(node) {
@@ -128,7 +127,7 @@
function unwrapNode(item) {
if (!item || typeof item !== 'object') return item;
return item.media || item.node || item.post || item;
return item.media_or_ad || item.media || item.node || item.post || item;
}
function filterList(list) {
@@ -165,6 +164,8 @@
}
for (const key in obj) {
// Never filter pagination or connection metadata
if (key === 'page_info' || key === 'pagination_info' || key === 'paging_info') continue;
if (Object.prototype.hasOwnProperty.call(obj, key) && key !== '__typename') {
const val = obj[key];
if (val && typeof val === 'object') {
@@ -174,6 +175,62 @@
}
}
window.__fg_media_harvest = window.__fg_media_harvest || {};
window.__fg_recent_media = window.__fg_recent_media || [];
function harvestMediaFromData(obj, depth = 0) {
if (!obj || typeof obj !== 'object' || depth > 12) return;
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) harvestMediaFromData(obj[i], depth + 1);
return;
}
const code = obj.code || (obj.shortcode ? obj.shortcode : null);
if (code && (obj.video_versions || obj.image_versions2 || obj.carousel_media)) {
const isVideo = !!(obj.is_video || (obj.video_versions && obj.video_versions.length > 0));
const videoUrl = (obj.video_versions && obj.video_versions[0]?.url) || '';
const imageUrl = (obj.image_versions2?.candidates && obj.image_versions2.candidates[0]?.url) || '';
const carousel = [];
if (Array.isArray(obj.carousel_media)) {
for (let c = 0; c < obj.carousel_media.length; c++) {
const cItem = obj.carousel_media[c];
const cIsVideo = !!(cItem.is_video || (cItem.video_versions && cItem.video_versions.length > 0));
const cVideoUrl = (cItem.video_versions && cItem.video_versions[0]?.url) || '';
const cImageUrl = (cItem.image_versions2?.candidates && cItem.image_versions2.candidates[0]?.url) || '';
carousel.push({
url: cIsVideo && cVideoUrl ? cVideoUrl : cImageUrl,
videoUrl: cVideoUrl,
imageUrl: cImageUrl,
isVideo: cIsVideo && !!cVideoUrl
});
}
}
const entry = {
code: code,
videoUrl: videoUrl,
imageUrl: imageUrl,
url: isVideo && videoUrl ? videoUrl : (imageUrl || videoUrl),
isVideo: isVideo && !!videoUrl,
carousel: carousel,
author: obj.user?.username || obj.owner?.username || ''
};
window.__fg_media_harvest[code] = entry;
window.__fg_recent_media.unshift(entry);
if (window.__fg_recent_media.length > 100) {
window.__fg_recent_media.pop();
}
}
for (const k in obj) {
if (Object.prototype.hasOwnProperty.call(obj, k) && k !== '__typename' && typeof obj[k] === 'object') {
harvestMediaFromData(obj[k], depth + 1);
}
}
}
// Override fetch in the main world
const _fetch = window.fetch.bind(window);
@@ -204,12 +261,20 @@
}
const data = await cloned.json();
harvestMediaFromData(data);
walkAndFilter(data);
const newHeaders = new Headers(response.headers);
// CRITICAL: Must delete content-length and content-encoding so the uncompressed
// JSON body is not truncated or corrupted by the browser HTTP layer
newHeaders.delete('content-length');
newHeaders.delete('content-encoding');
newHeaders.set('content-type', 'application/json; charset=utf-8');
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
headers: response.headers
headers: newHeaders
});
} catch (_) {
return response;
File diff suppressed because it is too large. Load diff
@@ -12,6 +12,19 @@
'use strict';
(function () {
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return true;
} catch (_) {}
return false;
}
if (isAuthPage()) return; // Leave login/signup completely untouched
if (window.__fgNativeFeelInitialized) return;
window.__fgNativeFeelInitialized = true;
@@ -104,17 +117,112 @@
}
}
function enhanceFileInputsForVideoSupport() {
function patchInput(input) {
if (!input || input.tagName !== 'INPUT' || input.type !== 'file') return;
// Never patch profile avatar uploads
const path = window.location.pathname.toLowerCase();
if (path.includes('/accounts/edit')) return;
const currentAccept = input.getAttribute('accept') || '';
if (currentAccept && currentAccept.includes('image') && !currentAccept.includes('video')) {
// Broaden accept attribute to allow video formats (MP4, MOV, WebM, etc.)
input.setAttribute('accept', currentAccept + ',video/mp4,video/quicktime,video/webm,video/*');
} else if (!currentAccept) {
input.setAttribute('accept', 'image/*,video/*');
}
}
// Patch any existing inputs immediately
document.querySelectorAll('input[type="file"]').forEach(patchInput);
// Intercept pointer/click before it triggers onFilePrompt to guarantee accept attribute is expanded
document.addEventListener('pointerdown', function(e) {
const target = e.target;
if (!target) return;
const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label');
const fileInput = (target.tagName === 'INPUT' && target.type === 'file') ? target :
(trigger ? (trigger.querySelector('input[type="file"]') || (trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null)) : null);
if (fileInput) {
patchInput(fileInput);
}
}, { capture: true, passive: true });
// Observe dynamically created upload dialogs (e.g. DM media buttons, Story camera, New Post)
const observer = new MutationObserver(function(mutations) {
for (let i = 0; i < mutations.length; i++) {
const added = mutations[i].addedNodes;
for (let j = 0; j < added.length; j++) {
const node = added[j];
if (node.nodeType === 1) {
if (node.tagName === 'INPUT' && node.type === 'file') {
patchInput(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('input[type="file"]').forEach(patchInput);
}
}
}
}
});
observer.observe(document.documentElement, { childList: true, subtree: true });
}
function setupMediaUploadBridge() {
// Ensures user taps on Instagram media upload triggers (Story +, Post +, DMs photo, Profile photo)
// reliably trigger the underlying input[type="file"] element.
document.addEventListener('click', function(e) {
const target = e.target;
if (!target) return;
if (target.tagName === 'INPUT' && target.type === 'file') return;
const trigger = target.closest('button, [role="button"], a, div[role="menuitem"], label');
if (!trigger) return;
const fileInput = trigger.querySelector('input[type="file"]') ||
(trigger.parentElement ? trigger.parentElement.querySelector('input[type="file"]') : null);
if (fileInput && fileInput !== target) {
try {
fileInput.click();
} catch (_) {}
}
}, { capture: true, passive: true });
}
function preventLandscapeOrientationLock() {
// Blocks web pages / HTML5 video players from forcing device orientation into landscape
// when portrait-only mode is active in FocusGram settings.
try {
if (screen.orientation && typeof screen.orientation.lock === 'function') {
const origLock = screen.orientation.lock.bind(screen.orientation);
screen.orientation.lock = function(orientation) {
const isPortraitOnly = !window.__FOCUSGRAM_CONFIG__ || window.__FOCUSGRAM_CONFIG__.portraitOnly !== false;
if (isPortraitOnly && orientation && typeof orientation === 'string' && orientation.includes('landscape')) {
return Promise.resolve();
}
return origLock(orientation);
};
}
} catch (_) {}
}
// Initialize immediately or upon DOM ready
injectNativeStyles();
applyScrollSmoothing();
setupKeyboardViewportFix();
setupMediaUploadBridge();
enhanceFileInputsForVideoSupport();
preventLandscapeOrientationLock();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
injectNativeStyles();
applyScrollSmoothing();
setupMediaUploadBridge();
enhanceFileInputsForVideoSupport();
preventLandscapeOrientationLock();
});
}
console.log('[FocusGram] Native Feel & Touch Smoothing active.');
console.log('[FocusGram] Native Feel, Touch Smoothing, Media Upload Bridge & Video Support active.');
})();
@@ -1,5 +1,6 @@
package com.ujwal.focusgram
import android.content.pm.ActivityInfo
import android.os.Build
import android.os.Bundle
import androidx.fragment.app.FragmentActivity
@@ -16,6 +17,7 @@ import com.ujwal.focusgram.ui.FocusGramApp
class MainActivity : FragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
enableEdgeToEdge()
applyHighRefreshRate()
setContent {
@@ -74,10 +74,11 @@ class SettingsRepository(private val context: Context) {
isFirstRun = prefs[KEY_IS_FIRST_RUN] ?: false,
startupPage = prefs[KEY_STARTUP_PAGE] ?: "home",
videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: true,
videoDownloadEnabled = prefs[KEY_VIDEO_DOWNLOAD_ENABLED] ?: false,
sanitizeLinks = prefs[KEY_SANITIZE_LINKS] ?: true,
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true,
ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false,
ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true,
@@ -154,6 +155,7 @@ class SettingsRepository(private val context: Context) {
suspend fun setSanitizeLinks(enabled: Boolean) = setBoolean(KEY_SANITIZE_LINKS, enabled)
suspend fun setNotifySessionEnd(enabled: Boolean) = setBoolean(KEY_NOTIFY_SESSION_END, enabled)
suspend fun setNotifyPersistent(enabled: Boolean) = setBoolean(KEY_NOTIFY_PERSISTENT, enabled)
suspend fun setPortraitOnly(enabled: Boolean) = setBoolean(KEY_PORTRAIT_ONLY, enabled)
suspend fun addInstaMinutes(minutes: Int) {
context.dataStore.edit { prefs ->
@@ -286,6 +288,7 @@ class SettingsRepository(private val context: Context) {
val KEY_SANITIZE_LINKS = booleanPreferencesKey("set_sanitize_links")
val KEY_NOTIFY_SESSION_END = booleanPreferencesKey("set_notify_session_end")
val KEY_NOTIFY_PERSISTENT = booleanPreferencesKey("set_notify_persistent")
val KEY_PORTRAIT_ONLY = booleanPreferencesKey("portrait_only")
val KEY_GHOST_MODE_ENABLED = booleanPreferencesKey("ghost_mode_enabled")
val KEY_GHOST_HIDE_STORY_SEEN = booleanPreferencesKey("ghost_hide_story_seen")
@@ -34,6 +34,9 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
var onReelViewed: ((url: String, title: String, author: String, thumbnail: String) -> Unit)? = null
var onReelMeta: ((url: String, title: String, author: String) -> Unit)? = null
var onDownloadRequested: ((url: String, filename: String) -> Unit)? = null
var onDownloadError: ((error: String) -> Unit)? = null
var onDownloadStarted: ((filename: String) -> Unit)? = null
var onAuthStateChanged: ((isAuthenticated: Boolean, isAuthPage: Boolean) -> Unit)? = null
/**
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
@@ -42,9 +45,16 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
contentBlockerExtension?.let { return Result.success(it) }
return try {
Log.i(TAG, "Mounting built-in Content Blocker WebExtension from assets...")
val extension = runtime.webExtensionController
.ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID)
.await()
val extension = try {
runtime.webExtensionController
.installBuiltIn(CONTENT_BLOCKER_ASSET_URI)
.await()
} catch (e: Exception) {
Log.w(TAG, "installBuiltIn fallback to ensureBuiltIn: ${e.message}")
runtime.webExtensionController
.ensureBuiltIn(CONTENT_BLOCKER_ASSET_URI, CONTENT_BLOCKER_EXTENSION_ID)
.await()
}
if (extension != null) {
try {
@@ -177,12 +187,32 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
}
}
}
"downloadStarted" -> {
val filename = json.optString("filename")
Log.d(TAG, "Received downloadStarted from web: filename=$filename")
mainHandler.post {
onDownloadStarted?.invoke(filename)
}
}
"downloadMedia" -> {
val mediaUrl = json.optString("url")
val filename = json.optString("filename")
Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename")
val error = json.optString("error")
Log.d(TAG, "Received downloadMedia from web: url=$mediaUrl, filename=$filename, error=$error")
mainHandler.post {
onDownloadRequested?.invoke(mediaUrl, filename)
if (mediaUrl.isNotBlank()) {
onDownloadRequested?.invoke(mediaUrl, filename)
} else if (error.isNotBlank()) {
onDownloadError?.invoke(error)
}
}
}
"authStateChanged" -> {
val isAuthenticated = json.optBoolean("isAuthenticated", true)
val isAuthPage = json.optBoolean("isAuthPage", false)
Log.d(TAG, "Received authStateChanged from web: isAuthenticated=$isAuthenticated, isAuthPage=$isAuthPage")
mainHandler.post {
onAuthStateChanged?.invoke(isAuthenticated, isAuthPage)
}
}
"getSettings" -> {
@@ -219,6 +249,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
put("grayscale", settings.grayscaleEnabled)
put("minimalMode", settings.minimalModeEnabled)
put("videoDownloadEnabled", settings.videoDownloadEnabled)
put("portraitOnly", settings.portraitOnly)
put("ghostModeEnabled", settings.ghostModeEnabled)
put("ghostHideStorySeen", settings.ghostHideStorySeen)
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
@@ -38,6 +38,23 @@ class FocusGramNavigationDelegate(
return GeckoResult.fromValue(AllowOrDeny.DENY)
}
// 2b. Intercept native media download requests from in-page client scripts
if (scheme == "focusgram-download") {
val mediaUrl = uri.getQueryParameter("url") ?: ""
val filename = uri.getQueryParameter("filename") ?: ""
val error = uri.getQueryParameter("error") ?: ""
Log.d(TAG, "Intercepted focusgram-download: url=$mediaUrl, filename=$filename, error=$error")
val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
mainHandler.post {
if (mediaUrl.isNotBlank()) {
com.ujwal.focusgram.core.utils.MediaDownloadHelper.download(context, mediaUrl, filename)
} else if (error.isNotBlank()) {
android.widget.Toast.makeText(context, error, android.widget.Toast.LENGTH_SHORT).show()
}
}
return GeckoResult.fromValue(AllowOrDeny.DENY)
}
// 3. Intercept navigation trap to suggested accounts (/explore/people) from Favorites
val path = uri.path?.lowercase() ?: ""
if (path.contains("/explore/people")) {
@@ -82,6 +99,7 @@ class FocusGramNavigationDelegate(
hasUserGesture: Boolean
) {
url?.let {
if (it.startsWith("focusgram-download:")) return
if (it.lowercase().contains("/explore/people")) {
Log.i(TAG, "Location redirected away from /explore/people to home")
sessionManager.loadUrl("https://www.instagram.com/")
@@ -30,7 +30,18 @@ class FocusGramPermissionDelegate(
ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED
}
if (ungranted.isEmpty()) {
// On Android 13+ (API 33+), READ_EXTERNAL_STORAGE and WRITE_EXTERNAL_STORAGE are deprecated
// and automatically denied by the OS. System file pickers do not require storage permissions.
val actionableUngranted = if (android.os.Build.VERSION.SDK_INT >= 33) {
ungranted.filter {
it != android.Manifest.permission.READ_EXTERNAL_STORAGE &&
it != android.Manifest.permission.WRITE_EXTERNAL_STORAGE
}
} else {
ungranted
}
if (actionableUngranted.isEmpty()) {
callback.grant()
return
}
@@ -38,12 +49,14 @@ class FocusGramPermissionDelegate(
if (onAndroidPermissionRequest != null) {
Handler(Looper.getMainLooper()).post {
try {
onAndroidPermissionRequest.invoke(ungranted.toTypedArray()) { allGranted ->
if (allGranted) callback.grant() else callback.reject()
onAndroidPermissionRequest.invoke(actionableUngranted.toTypedArray()) { granted ->
// Always grant to GeckoView so it proceeds to onFilePrompt() where
// native ActivityResultContracts (Photo Picker / SAF) handle file access securely.
callback.grant()
}
} catch (e: Exception) {
Log.e(TAG, "Error requesting Android permissions: ${e.message}", e)
callback.reject()
callback.grant()
}
}
} else {
@@ -24,6 +24,7 @@ class FocusGramPromptDelegate(
fun onPickFiles(
mimeTypes: Array<String>,
allowMultiple: Boolean,
capture: Int,
onResult: (List<Uri>) -> Unit
)
}
@@ -34,12 +35,15 @@ class FocusGramPromptDelegate(
): GeckoResult<GeckoSession.PromptDelegate.PromptResponse> {
val handler = filePickerHandler
if (handler == null) {
Log.d(TAG, "No FilePickerHandler attached. Dismissing file prompt.")
Log.w(TAG, "No FilePickerHandler attached. Dismissing file prompt.")
return GeckoResult.fromValue(prompt.dismiss())
}
val mimeTypes = prompt.mimeTypes ?: arrayOf("*/*")
val isMultiple = prompt.type == GeckoSession.PromptDelegate.FilePrompt.Type.MULTIPLE
val capture = prompt.capture
Log.i(TAG, "onFilePrompt triggered: type=${prompt.type}, isMultiple=$isMultiple, capture=$capture, mimeTypes=${mimeTypes.joinToString()}")
val result = GeckoResult<GeckoSession.PromptDelegate.PromptResponse>()
@@ -47,13 +51,16 @@ class FocusGramPromptDelegate(
// ActivityResultLauncher.launch() MUST be called on the main Looper thread.
Handler(Looper.getMainLooper()).post {
try {
handler.onPickFiles(mimeTypes, isMultiple) { uris ->
handler.onPickFiles(mimeTypes, isMultiple, capture) { uris ->
try {
if (uris.isEmpty()) {
Log.i(TAG, "File picker dismissed or canceled by user.")
result.complete(prompt.dismiss())
} else if (isMultiple) {
Log.i(TAG, "Confirming multiple files (${uris.size}) to GeckoView: ${uris.joinToString()}")
result.complete(prompt.confirm(context, uris.toTypedArray()))
} else {
Log.i(TAG, "Confirming single file to GeckoView: ${uris.first()}")
result.complete(prompt.confirm(context, uris.first()))
}
} catch (e: Exception) {
@@ -44,6 +44,21 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
val instagramTheme: StateFlow<String> = _instagramTheme.asStateFlow()
private var isOpen = false
var isAuthenticated: Boolean = false
private set
fun setAuthenticated(auth: Boolean) {
val changed = isAuthenticated != auth
isAuthenticated = auth
if (auth && changed) {
val url = _currentUrl.value.lowercase()
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
if (!isAuth) {
injectBottomBarTrimmer()
userScriptManager?.injectAll(session, currentSettings)
}
}
}
init {
val settings = GeckoSessionSettings.Builder()
@@ -51,6 +66,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
.suspendMediaWhenInactive(true)
.allowJavascript(true)
.userAgentMode(GeckoSessionSettings.USER_AGENT_MODE_MOBILE)
.userAgentOverride(IOS_USER_AGENT)
.usePrivateMode(false) // Retain Instagram authentication cookies locally
.build()
@@ -62,9 +78,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
session.progressDelegate = object : GeckoSession.ProgressDelegate {
override fun onPageStart(session: GeckoSession, url: String) {
Log.d(TAG, "Page started loading: $url")
_isLoading.value = true
_currentUrl.value = url
_progress.value = 10
if (!url.startsWith("focusgram-download:") && !url.startsWith("javascript:")) {
_isLoading.value = true
_currentUrl.value = url
_progress.value = 10
}
}
override fun onPageStop(session: GeckoSession, success: Boolean) {
@@ -72,8 +90,12 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
_isLoading.value = false
_progress.value = 100
if (success) {
injectBottomBarTrimmer()
userScriptManager?.injectAll(session, currentSettings)
val url = _currentUrl.value.lowercase()
val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
if (isAuthenticated && !isAuth) {
injectBottomBarTrimmer()
userScriptManager?.injectAll(session, currentSettings)
}
}
}
@@ -145,7 +167,9 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
fun updateSettings(settings: FocusSettings) {
currentSettings = settings
userScriptManager?.injectConfig(session, settings)
if (isAuthenticated) {
userScriptManager?.injectConfig(session, settings)
}
}
fun updateCurrentUrl(url: String) {
@@ -275,7 +299,11 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
fun injectBottomBarTrimmer() {
val js = """javascript:(function() {
try {
if (window.location.pathname.startsWith('/direct')) return;
var p = (window.location.pathname || '').toLowerCase();
if (p.startsWith('/direct') || p.startsWith('/accounts') || p.startsWith('/login') || p.startsWith('/signup')) return;
var c = document.cookie || '';
if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return;
document.querySelectorAll('a[href="/"], a[href="/?variant=home"], svg[aria-label="Home"], a[href*="/explore/"]').forEach(function(target) {
var el = target.closest('a') || target;
while (el && el !== document.body && el !== document.documentElement) {
@@ -50,7 +50,8 @@ class UserScriptManager(private val context: Context) {
blockAutoplay: ${settings.noAutoplay},
grayscale: ${settings.grayscaleEnabled},
minimalMode: ${settings.minimalModeEnabled},
videoDownloadEnabled: ${settings.videoDownloadEnabled}
videoDownloadEnabled: ${settings.videoDownloadEnabled},
portraitOnly: ${settings.portraitOnly}
};
window.__FOCUSGRAM_GHOST_CONFIG__ = {
enabled: ${settings.ghostModeEnabled},
@@ -121,6 +122,7 @@ class UserScriptManager(private val context: Context) {
bundle.append(loadScript("autoplay_blocker.js")).append("\n")
bundle.append(loadScript("grayscale.js")).append("\n")
bundle.append(loadScript("native_feel.js")).append("\n")
bundle.append(loadScript("media_downloader.js")).append("\n")
evaluate(session, bundle.toString())
injectConfig(session, settings)
}
@@ -58,10 +58,11 @@ data class FocusSettings(
// Extras & Notifications
val startupPage: String = "home",
val videoDownloadEnabled: Boolean = true,
val videoDownloadEnabled: Boolean = false,
val sanitizeLinks: Boolean = true,
val notifySessionEnd: Boolean = false,
val notifyPersistent: Boolean = false,
val portraitOnly: Boolean = true,
// Ghost Mode (Privacy & Stealth)
val ghostModeEnabled: Boolean = false,
@@ -8,6 +8,7 @@ import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.view.HapticFeedbackConstants
import com.ujwal.focusgram.core.utils.HapticHelper
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@@ -383,41 +384,10 @@ private fun getVibrator(context: Context): Vibrator? {
/**
* Perform crisp haptic tick even when user has system "touch feedback" disabled.
* Uses both View ignore flags, and falls back to physical Vibrator with USAGE_ALARM.
* Uses direct physical vibration (35ms pulse) to ensure tactile feedback on all devices
* (both ERM and LRA motors, like MX Player), without relying on unsupported predefined effects.
*/
private fun performWheelHaptic(context: Context, view: android.view.View) {
try {
// 1. Try View haptics with ignore flags
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
} else {
@Suppress("DEPRECATION")
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING or HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
}
val performed = view.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK, flags)
if (performed) return
// 2. Hardware Vibrator fallback with USAGE_ALARM to bypass system touch vibration disablement
val vibrator = getVibrator(context) ?: return
if (!vibrator.hasVibrator()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val attributes = VibrationAttributes.Builder()
.setUsage(VibrationAttributes.USAGE_ALARM)
.build()
val effect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK)
vibrator.vibrate(effect, attributes)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val audioAttributes = AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
val effect = VibrationEffect.createOneShot(12L, VibrationEffect.DEFAULT_AMPLITUDE)
@Suppress("DEPRECATION")
vibrator.vibrate(effect, audioAttributes)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(10L)
}
} catch (_: Exception) {}
HapticHelper.performWheelTick(context, view)
}
@@ -0,0 +1,173 @@
package com.ujwal.focusgram.core.utils
import android.content.Context
import android.os.Build
import android.os.SystemClock
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.HapticFeedbackConstants
import android.view.View
/**
* HapticHelper
* Universal haptic feedback and vibration utility for FocusGram.
*
* Guarantees physical vibration across all hardware motors (ERM & LRA),
* regardless of whether system "Touch feedback" is disabled in user Settings.
* Emulates the proven direct-hardware approach of media apps like MX Player
* by pulsing the physical vibrator directly with sufficient duration (35ms)
* to overcome motor inertia without relying on unsupported predefined effects.
*/
object HapticHelper {
private const val TAG = "HapticHelper"
private var lastWheelTickTime = 0L
/**
* Retrieves the system Vibrator service using modern, non-deprecated APIs.
*/
fun getVibrator(context: Context): Vibrator? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val vibratorManager = context.getSystemService(VibratorManager::class.java)
vibratorManager?.defaultVibrator
} else {
context.getSystemService(Vibrator::class.java)
}
}
/**
* Performs a crisp, responsive drum-wheel scroll tick.
* Direct hardware pulse of 35ms ensures ERM motors overcome inertia and
* rotate to produce an unmistakable tactile click, while LRA motors give a crisp bump.
* Debounced at 35ms to ensure smooth performance during rapid flings.
*/
fun performWheelTick(context: Context, view: View? = null) {
val now = SystemClock.uptimeMillis()
if (now - lastWheelTickTime < 35L) return
lastWheelTickTime = now
val vibrator = getVibrator(context)
if (vibrator != null && vibrator.hasVibrator()) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val effect = VibrationEffect.createOneShot(35L, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(35L)
}
} catch (e: Exception) {
Log.w(TAG, "Hardware wheel tick failed: ${e.message}")
}
}
// Companion View haptic for devices that integrate with platform accessibility
try {
view?.performHapticFeedback(
HapticFeedbackConstants.CLOCK_TICK,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
)
} catch (_: Exception) {}
}
/**
* Performs an alert vibration pattern when a focus session timer expires,
* daily limit is exhausted, or the lock screen activates.
* Pattern: Buzz 280ms -> Pause 140ms -> Buzz 280ms.
*/
fun performSessionCompletionAlert(context: Context) {
val vibrator = getVibrator(context) ?: return
if (!vibrator.hasVibrator()) return
try {
val pattern = longArrayOf(0, 280, 140, 280)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val effect = VibrationEffect.createWaveform(pattern, -1)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(pattern, -1)
}
Log.d(TAG, "Session completion vibration triggered successfully")
} catch (e: Exception) {
Log.e(TAG, "Failed to vibrate for session completion: ${e.message}", e)
}
}
/**
* Performs a tactile click pulse (25ms) when a tab or button is tapped.
* Uses direct motor pulse so devices with ERM or LRA motors vibrate regardless of
* system touch-feedback toggles, matching media apps like MX Player.
*/
fun performClick(context: Context, view: View? = null) {
val vibrator = getVibrator(context)
if (vibrator != null && vibrator.hasVibrator()) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val effect = VibrationEffect.createOneShot(25L, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(25L)
}
} catch (e: Exception) {
Log.w(TAG, "Hardware click vibration failed: ${e.message}")
}
}
try {
view?.performHapticFeedback(
HapticFeedbackConstants.VIRTUAL_KEY,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
)
} catch (_: Exception) {}
}
/**
* Performs a tactile long-press pulse (60ms) when long-pressing an element.
*/
fun performLongPress(context: Context, view: View? = null) {
val vibrator = getVibrator(context)
if (vibrator != null && vibrator.hasVibrator()) {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(60L)
}
} catch (e: Exception) {
Log.w(TAG, "Hardware long-press vibration failed: ${e.message}")
}
}
try {
view?.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_VIEW_SETTING
)
} catch (_: Exception) {}
}
/**
* Performs a single medium confirmation pulse (60ms) when completing
* a mindful breath gate or cooldown waiting period.
*/
fun performSuccessPulse(context: Context) {
val vibrator = getVibrator(context) ?: return
if (!vibrator.hasVibrator()) return
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val effect = VibrationEffect.createOneShot(60L, VibrationEffect.DEFAULT_AMPLITUDE)
vibrator.vibrate(effect)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(60L)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to vibrate success pulse: ${e.message}", e)
}
}
}
@@ -10,9 +10,25 @@ import android.provider.MediaStore
import android.util.Base64
import android.util.Log
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
data class DownloadProgressState(
val isDownloading: Boolean = false,
val progress: Float = 0f, // 0f to 1f
val filename: String = "",
val isCompleted: Boolean = false,
val isFailed: Boolean = false
)
/**
* MediaDownloadHelper
* Native client-side media downloader using Android System DownloadManager & MediaStore.
@@ -21,20 +37,154 @@ import java.io.FileOutputStream
object MediaDownloadHelper {
private const val TAG = "MediaDownloadHelper"
private val _downloadProgressFlow = MutableStateFlow(DownloadProgressState())
val downloadProgressFlow: StateFlow<DownloadProgressState> = _downloadProgressFlow.asStateFlow()
private val helperScope = CoroutineScope(Dispatchers.IO)
private var progressJob: Job? = null
private var lastDownloadUrl: String = ""
private var lastDownloadTimestamp: Long = 0L
/**
* Determines whether media is a video based on explicit URL and filename markers.
* Note: NEVER treat "/v/" in CDN URLs as video because all Meta CDN photos contain "/v/".
*/
fun detectIsVideo(url: String, filename: String = ""): Boolean {
val lowerUrl = url.lowercase()
val lowerFile = filename.lowercase()
if (lowerUrl.contains(".jpg") || lowerUrl.contains(".jpeg") || lowerUrl.contains(".png") ||
lowerUrl.contains(".webp") || lowerUrl.contains("dst-jpg") || lowerUrl.contains("dst-png") ||
lowerUrl.contains("dst-webp") || lowerUrl.startsWith("data:image") ||
lowerFile.endsWith(".jpg") || lowerFile.endsWith(".jpeg") ||
lowerFile.endsWith(".png") || lowerFile.endsWith(".webp")
) {
return false
}
if (lowerUrl.contains(".mp4") || lowerUrl.contains(".webm") || lowerUrl.contains("video_dashinit") ||
lowerUrl.contains("/v/t50.") || lowerUrl.contains("/v/t16.") ||
lowerUrl.startsWith("data:video") || lowerFile.endsWith(".mp4")
) {
return true
}
return false
}
/**
* Formats target filename according to FocusGram specification:
* Author name (first 10-13 characters) followed by ' - Focusgram.<ext>'
* E.g. '@ujwal - Focusgram.mp4' or '@ujwal - Focusgram.jpg'.
*/
fun formatTargetFilename(rawFilename: String, isVideo: Boolean): String {
val extension = if (isVideo) "mp4" else "jpg"
var clean = rawFilename.trim()
// Strip known extension if present
if (clean.endsWith(".mp4", ignoreCase = true) || clean.endsWith(".jpg", ignoreCase = true) ||
clean.endsWith(".jpeg", ignoreCase = true) || clean.endsWith(".png", ignoreCase = true)
) {
clean = clean.substringBeforeLast('.')
}
// Handle slide suffix (e.g. _2 for carousel slides)
var slideSuffix = ""
val slideMatch = Regex("_(\\d+)$").find(clean)
if (slideMatch != null) {
slideSuffix = "_" + slideMatch.groupValues[1]
clean = clean.removeSuffix(slideSuffix)
}
// Extract author prefix
val authorName = when {
clean.startsWith("@") -> clean.removePrefix("@").substringBefore(" - ").substringBefore('_').trim()
clean.contains(" - Focusgram", ignoreCase = true) -> {
val idx = clean.indexOf(" - Focusgram", ignoreCase = true)
if (idx != -1) clean.substring(0, idx).trim().removePrefix("@") else ""
}
clean.startsWith("instagram_", ignoreCase = true) -> ""
clean.startsWith("Focusgram", ignoreCase = true) -> ""
clean.isNotBlank() -> clean.substringBefore('_').trim().removePrefix("@")
else -> ""
}
val sanitizedAuthor = authorName
.replace(Regex("[^a-zA-Z0-9._-]"), "")
.take(13)
.ifBlank { "Focusgram" }
val prefix = if (sanitizedAuthor.equals("Focusgram", ignoreCase = true)) "@Focusgram" else "@$sanitizedAuthor"
return "$prefix - Focusgram$slideSuffix.$extension"
}
/**
* Starts visual perimeter progress in the bottom nav bar while URL is resolving.
* Starts at 0.05f without fake timer loops so visual pace reflects actual network download.
*/
fun startIndeterminateProgress(filename: String = "") {
progressJob?.cancel()
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = 0.05f,
filename = filename,
isCompleted = false,
isFailed = false
)
}
/**
* Reverses progress bar from current position back to start in red/dark-red and cancels animation.
*/
fun triggerDownloadFailure(context: Context, message: String = "Download failed") {
progressJob?.cancel()
val startProgress = _downloadProgressFlow.value.progress.coerceAtLeast(0.12f)
progressJob = helperScope.launch {
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = startProgress,
filename = "",
isCompleted = false,
isFailed = true
)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
val steps = 20
for (i in steps downTo 0) {
delay(20L) // 20 * 20ms = ~400ms smooth reverse animation
_downloadProgressFlow.value = _downloadProgressFlow.value.copy(
progress = startProgress * (i.toFloat() / steps)
)
}
delay(60L)
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 0f,
filename = "",
isCompleted = false,
isFailed = false
)
}
}
fun download(context: Context, url: String, filename: String = "") {
if (url.isBlank()) {
Toast.makeText(context, "No media URL found to download", Toast.LENGTH_SHORT).show()
triggerDownloadFailure(context, "No media URL found to download")
return
}
val now = System.currentTimeMillis()
if (url == lastDownloadUrl && (now - lastDownloadTimestamp) < 1500L) {
Log.d(TAG, "Ignoring duplicate download trigger within debounce window")
return
}
lastDownloadUrl = url
lastDownloadTimestamp = now
try {
val isVideo = url.contains(".mp4") || url.contains("video") || url.contains("/v/") || url.startsWith("data:video")
val extension = if (isVideo) "mp4" else "jpg"
val targetFilename = if (filename.isNotBlank()) {
if (filename.contains(".")) filename else "$filename.$extension"
} else {
"FocusGram_${System.currentTimeMillis()}.$extension"
}
val isVideo = detectIsVideo(url, filename)
val targetFilename = formatTargetFilename(filename, isVideo)
progressJob?.cancel()
if (url.startsWith("data:")) {
val commaIndex = url.indexOf(',')
@@ -42,8 +192,18 @@ object MediaDownloadHelper {
val base64Data = url.substring(commaIndex + 1)
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
saveBytesToDownloads(context, bytes, targetFilename, isVideo)
Toast.makeText(context, "Saved to Downloads: $targetFilename", Toast.LENGTH_SHORT).show()
Log.i(TAG, "Saved data URI to Downloads: $targetFilename")
progressJob = helperScope.launch {
_downloadProgressFlow.value = DownloadProgressState(isDownloading = true, progress = 0.5f, filename = targetFilename)
delay(150L)
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 1.0f, filename = targetFilename, isCompleted = true)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
}
delay(1200L)
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f, filename = "", isCompleted = false)
}
return
}
}
@@ -63,27 +223,216 @@ object MediaDownloadHelper {
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
if (manager != null) {
manager.enqueue(request)
Toast.makeText(context, "Download started: $targetFilename", Toast.LENGTH_SHORT).show()
Log.i(TAG, "Download enqueued for $targetFilename from $url")
val downloadId = try {
manager.enqueue(request)
} catch (e: Exception) {
Log.w(TAG, "DownloadManager.enqueue failed, falling back to direct HTTP: ${e.message}")
downloadDirectHttp(context, url, targetFilename, isVideo)
return
}
Log.i(TAG, "Download enqueued for $targetFilename (id=$downloadId) from $url")
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = 0.08f,
filename = targetFilename,
isCompleted = false,
isFailed = false
)
progressJob = helperScope.launch {
var lastProgress = 0.08f
var isDone = false
val startTime = System.currentTimeMillis()
while (!isDone && (System.currentTimeMillis() - startTime < 60000L)) {
delay(120L)
try {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = manager.query(query)
if (cursor != null && cursor.moveToFirst()) {
val bytesCol = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val totalCol = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
val reasonCol = cursor.getColumnIndex(DownloadManager.COLUMN_REASON)
val bytesDownloaded = if (bytesCol != -1) cursor.getLong(bytesCol) else 0L
val totalBytes = if (totalCol != -1) cursor.getLong(totalCol) else -1L
val status = if (statusCol != -1) cursor.getInt(statusCol) else -1
val reason = if (reasonCol != -1) cursor.getInt(reasonCol) else -1
cursor.close()
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
isDone = true
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 1.0f,
filename = targetFilename,
isCompleted = true,
isFailed = false
)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
}
delay(1200L)
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 0f,
filename = "",
isCompleted = false
)
}
DownloadManager.STATUS_FAILED -> {
isDone = true
Log.w(TAG, "DownloadManager failed with reason $reason, retrying via direct HTTP")
downloadDirectHttp(context, url, targetFilename, isVideo)
}
else -> {
val computed = if (totalBytes > 0) {
(bytesDownloaded.toFloat() / totalBytes.toFloat()).coerceIn(0.08f, 0.98f)
} else {
(lastProgress + 0.02f).coerceAtMost(0.85f)
}
lastProgress = computed
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = computed,
filename = targetFilename,
isCompleted = false,
isFailed = false
)
}
}
} else {
cursor?.close()
if (lastProgress >= 0.7f) {
isDone = true
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 1.0f,
filename = targetFilename,
isCompleted = true,
isFailed = false
)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
}
delay(1200L)
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 0f,
filename = "",
isCompleted = false
)
}
}
} catch (e: Exception) {
Log.w(TAG, "Error checking download progress: ${e.message}")
}
}
}
} else {
Toast.makeText(context, "Download service unavailable", Toast.LENGTH_SHORT).show()
Log.w(TAG, "DownloadManager unavailable, falling back to direct HTTP")
downloadDirectHttp(context, url, targetFilename, isVideo)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to download media: ${e.message}", e)
Toast.makeText(context, "Download failed: ${e.message}", Toast.LENGTH_SHORT).show()
triggerDownloadFailure(context, "Download failed: ${e.message ?: "Unknown error"}")
}
}
private fun downloadDirectHttp(context: Context, url: String, targetFilename: String, isVideo: Boolean) {
helperScope.launch {
try {
Log.i(TAG, "Starting direct HTTP download for $targetFilename from $url")
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = 0.08f,
filename = targetFilename,
isCompleted = false,
isFailed = false
)
val connection = (java.net.URL(url).openConnection() as java.net.HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 15000
readTimeout = 30000
setRequestProperty("User-Agent", "Mozilla/5.0 (Linux; Android 14; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Mobile Safari/537.36")
setRequestProperty("Referer", "https://www.instagram.com/")
setRequestProperty("Accept", "*/*")
instanceFollowRedirects = true
}
connection.connect()
val code = connection.responseCode
if (code in 200..299) {
val contentLength = connection.contentLengthLong
val bytesOut = java.io.ByteArrayOutputStream()
connection.inputStream.use { input ->
val buffer = ByteArray(8192)
var read: Int
var downloaded = 0L
while (input.read(buffer).also { read = it } != -1) {
bytesOut.write(buffer, 0, read)
downloaded += read
if (contentLength > 0) {
val prog = (downloaded.toFloat() / contentLength.toFloat()).coerceIn(0.08f, 0.98f)
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = true,
progress = prog,
filename = targetFilename,
isCompleted = false,
isFailed = false
)
}
}
}
val data = bytesOut.toByteArray()
saveBytesToDownloads(context, data, targetFilename, isVideo)
_downloadProgressFlow.value = DownloadProgressState(
isDownloading = false,
progress = 1.0f,
filename = targetFilename,
isCompleted = true,
isFailed = false
)
android.os.Handler(android.os.Looper.getMainLooper()).post {
Toast.makeText(context, "Download complete", Toast.LENGTH_SHORT).show()
}
delay(1200L)
_downloadProgressFlow.value = DownloadProgressState(isDownloading = false, progress = 0f)
} else {
Log.e(TAG, "Direct HTTP download returned status $code")
triggerDownloadFailure(context, "Download failed (HTTP $code)")
}
} catch (e: Exception) {
Log.e(TAG, "Direct HTTP download error: ${e.message}", e)
triggerDownloadFailure(context, "Download failed: ${e.message ?: "Network error"}")
}
}
}
private fun saveBytesToDownloads(context: Context, bytes: ByteArray, filename: String, isVideo: Boolean) {
val mimeType = if (isVideo) "video/mp4" else "image/jpeg"
// Inspect magic bytes to verify whether bytes are an image or a video
val isActuallyImage = bytes.size >= 4 && (
(bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte()) || // JPEG
(bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte()) // PNG
)
val resolvedIsVideo = if (isActuallyImage) false else isVideo
val resolvedFilename = if (isActuallyImage && filename.endsWith(".mp4", ignoreCase = true)) {
filename.substringBeforeLast('.') + ".jpg"
} else {
filename
}
val mimeType = if (resolvedIsVideo) "video/mp4" else "image/jpeg"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, filename)
put(MediaStore.MediaColumns.DISPLAY_NAME, resolvedFilename)
put(MediaStore.MediaColumns.MIME_TYPE, mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val collection = if (isVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val collection = if (resolvedIsVideo) MediaStore.Video.Media.EXTERNAL_CONTENT_URI else MediaStore.Images.Media.EXTERNAL_CONTENT_URI
val itemUri = context.contentResolver.insert(collection, values)
if (itemUri != null) {
context.contentResolver.openOutputStream(itemUri)?.use { out ->
@@ -93,7 +442,7 @@ object MediaDownloadHelper {
} else {
@Suppress("DEPRECATION")
val downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
val file = File(downloadsDir, filename)
val file = File(downloadsDir, resolvedFilename)
FileOutputStream(file).use { out ->
out.write(bytes)
}
@@ -5,6 +5,7 @@ import android.view.HapticFeedbackConstants
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
@@ -21,6 +22,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.shadow
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -42,6 +44,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -59,11 +62,13 @@ import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.dp
import com.ujwal.focusgram.core.model.FocusSettings
import com.ujwal.focusgram.core.model.IslandMode
import com.ujwal.focusgram.core.utils.HapticHelper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -102,6 +107,12 @@ fun NativeBottomNavBar(
instagramTheme: String = "auto",
isWebInputFocused: Boolean = false,
isModalActive: Boolean = false,
downloadProgress: Float = 0f,
isDownloading: Boolean = false,
isDownloadCompleted: Boolean = false,
isDownloadFailed: Boolean = false,
isAuthenticated: Boolean = false,
isAuthPage: Boolean = false,
onNavClick: ((String) -> Unit)? = null,
onNavigate: (String) -> Unit,
onOpenProfile: () -> Unit,
@@ -110,6 +121,7 @@ fun NativeBottomNavBar(
onRefreshReels: () -> Unit = {},
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val view = LocalView.current
val density = LocalDensity.current
val scope = rememberCoroutineScope()
@@ -137,7 +149,8 @@ fun NativeBottomNavBar(
} else {
isInDirect
}
val isVisible = !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN
val isAuth = isAuthPage || !isAuthenticated || lowerUrl.contains("/accounts") || lowerUrl.contains("/login") || lowerUrl.contains("/signup")
val isVisible = !isAuth && !isImeVisible && !shouldHideInDirect && !isWebInputFocused && !isModalActive && islandMode != IslandMode.HIDDEN
// Real-time animated color palette matching Instagram's web theme
val isSystemDark = isSystemInDarkTheme()
@@ -148,18 +161,35 @@ fun NativeBottomNavBar(
}
val islandBackground by animateColorAsState(
targetValue = if (islandMode == IslandMode.FLOATING) {
if (isLight) Color(0x9CFFFFFF) else Color(0x8B101014)
if (isLight) Color(0xB0FFFFFF) else Color(0x9E101014)
} else {
if (isLight) Color(0x9EFFFFFF) else Color(0x9B141418)
if (isLight) Color(0xB2FFFFFF) else Color(0xAE141418)
},
animationSpec = tween(300),
label = "islandBackground"
)
val borderColor by animateColorAsState(
targetValue = if (isLight) Color(0x2B000000) else Color(0x4DFFFFFF),
targetValue = if (isLight) Color(0x35000000) else Color(0x57FFFFFF),
animationSpec = tween(300),
label = "borderColor"
)
val glassBorderBrush = remember(isLight) {
if (isLight) {
Brush.verticalGradient(
colors = listOf(
Color(0x3E000000), // Top specular rim (+4% glassy)
Color(0x20000000) // Bottom diffused rim
)
)
} else {
Brush.verticalGradient(
colors = listOf(
Color(0x60FFFFFF), // Top specular glass rim (+4% glassy)
Color(0x36FFFFFF) // Bottom subtle glass rim
)
)
}
}
val selectedTabBackground by animateColorAsState(
targetValue = if (isLight) Color(0x1F000000) else Color(0x33FFFFFF),
animationSpec = tween(300),
@@ -183,18 +213,80 @@ fun NativeBottomNavBar(
// Long press perimeter progress animation state
val longPressProgressAnim = remember { Animatable(0f) }
// Determine currently active tab index
val selectedTabIndex = activeTabs.indexOfFirst { tab ->
// Animated download progress state (smooth sweep)
val animatedDownloadProgress by animateFloatAsState(
targetValue = if (isDownloading || downloadProgress > 0f) downloadProgress else 0f,
animationSpec = if (isDownloadFailed) {
tween(durationMillis = 60, easing = LinearEasing)
} else if (downloadProgress >= 1f) {
tween(durationMillis = 200, easing = LinearEasing)
} else if (downloadProgress == 0f) {
tween(durationMillis = 350, easing = FastOutSlowInEasing)
} else {
tween(durationMillis = 180, easing = LinearEasing)
},
label = "animatedDownloadProgress"
)
// Glow animation for download completion (glow out and glow in 2 times)
val glowAnim = remember { Animatable(0f) }
androidx.compose.runtime.LaunchedEffect(isDownloadCompleted) {
if (isDownloadCompleted) {
// Pulse 1: glow out (0f -> 1f) and glow in (1f -> 0f)
glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
// Pulse 2: glow out (0f -> 1f) and glow in (1f -> 0f)
glowAnim.animateTo(1f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
glowAnim.animateTo(0f, animationSpec = tween(durationMillis = 260, easing = FastOutSlowInEasing))
} else {
glowAnim.snapTo(0f)
}
}
// Retain last valid active tab index so custom schemes/downloads never jump to Profile
var lastValidTabIndex by remember { mutableIntStateOf(0) }
val cleanPath = remember(currentUrl) {
val afterScheme = if (currentUrl.contains("://")) currentUrl.substringAfter("://") else currentUrl
val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else ""
pathPart.substringBefore('?').substringBefore('#').trim('/')
}
val isHome = cleanPath.isEmpty()
val rawTabIndex = activeTabs.indexOfFirst { tab ->
when (tab) {
NavTab.HOME -> lowerUrl == "https://www.instagram.com/" || lowerUrl.endsWith("instagram.com/") || lowerUrl == "/"
NavTab.HOME -> isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/"
NavTab.SEARCH -> lowerUrl.contains("/explore")
NavTab.REELS -> lowerUrl.contains("/reel")
NavTab.MESSAGES -> lowerUrl.contains("/direct") || lowerUrl.contains("/inbox")
NavTab.PROFILE -> !lowerUrl.contains("/explore") && !lowerUrl.contains("/reel") && !lowerUrl.contains("/direct") &&
(lowerUrl.contains("/profile") || (!lowerUrl.endsWith("instagram.com/") && !lowerUrl.endsWith("instagram.com") && lowerUrl != "/"))
NavTab.PROFILE -> {
val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/")
val isExcluded = isHome ||
lowerUrl.contains("/explore") ||
lowerUrl.contains("/reel") ||
lowerUrl.contains("/direct") ||
lowerUrl.contains("/inbox") ||
lowerUrl.contains("/p/") ||
lowerUrl.contains("/stories/") ||
lowerUrl.startsWith("focusgram-download:") ||
lowerUrl.startsWith("javascript:")
val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() }
val isSingleUserSegment = segments.size == 1
!isHome && isInstagram && !isExcluded &&
(lowerUrl.contains("/profile") || isSingleUserSegment)
}
NavTab.SETTINGS -> false
}
}.coerceAtLeast(0)
}
val selectedTabIndex = if (rawTabIndex >= 0) {
lastValidTabIndex = rawTabIndex
rawTabIndex
} else {
lastValidTabIndex.coerceIn(0, activeTabs.lastIndex)
}
AnimatedVisibility(
visible = isVisible,
@@ -219,15 +311,46 @@ fun NativeBottomNavBar(
.fillMaxWidth()
.height(56.dp)
.shadow(
elevation = if (isLight) 8.dp else 4.dp,
elevation = if (glowAnim.value > 0f) (8.dp + 12.dp * glowAnim.value) else (if (isLight) 8.dp else 4.dp),
shape = navShape,
spotColor = if (isLight) Color(0x33000000) else Color.Transparent
spotColor = if (glowAnim.value > 0f) Color(0xFF00D2FF).copy(alpha = 0.75f * glowAnim.value) else (if (isLight) Color(0x33000000) else Color.Transparent)
)
.clip(navShape)
.background(islandBackground)
.border(BorderStroke(1.dp, borderColor), navShape),
.border(BorderStroke(1.dp, glassBorderBrush), navShape),
contentAlignment = Alignment.Center
) {
// 0. Frosted Glass Blur Background Layer (~13% more blur)
Box(
modifier = Modifier
.fillMaxSize()
.clip(navShape)
.blur(20.dp)
.background(islandBackground)
)
// 0.1 Specular Glass Sheen Gradient (+4% glass reflection)
Box(
modifier = Modifier
.fillMaxSize()
.clip(navShape)
.background(
Brush.verticalGradient(
colors = if (isLight) {
listOf(
Color(0x24FFFFFF),
Color(0x08FFFFFF),
Color.Transparent
)
} else {
listOf(
Color(0x1CFFFFFF),
Color(0x06FFFFFF),
Color.Transparent
)
}
)
)
)
val totalWidthPx = constraints.maxWidth.toFloat()
val totalHeightPx = constraints.maxHeight.toFloat()
val tabCount = activeTabs.size
@@ -298,7 +421,7 @@ fun NativeBottomNavBar(
)
)
settingsOpened = true
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
HapticHelper.performLongPress(context, view)
longPressProgressAnim.snapTo(0f)
onOpenSettings()
} catch (_: CancellationException) {
@@ -313,7 +436,7 @@ fun NativeBottomNavBar(
animJob.cancel()
scope.launch { longPressProgressAnim.snapTo(0f) }
if (released) {
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
HapticHelper.performClick(context, view)
onOpenProfile()
onNavClick?.invoke("profile")
}
@@ -329,7 +452,7 @@ fun NativeBottomNavBar(
detectTapGestures(
onTap = {
val now = SystemClock.uptimeMillis()
view.performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY)
HapticHelper.performClick(context, view)
when (tab) {
NavTab.HOME -> {
@@ -453,6 +576,111 @@ fun NativeBottomNavBar(
)
}
}
// 4. Media Download Clockwise Perimeter Progress Animation & 2x Completion Glow
val currentDlProgress = animatedDownloadProgress
val currentGlow = glowAnim.value
if (currentDlProgress > 0f || currentGlow > 0f) {
Canvas(modifier = Modifier.fillMaxSize()) {
val fullPath = Path().apply {
moveTo(startXPx, 0f)
lineTo(totalWidthPx - cornerRadiusPx, 0f)
arcTo(
rect = Rect(totalWidthPx - 2 * cornerRadiusPx, 0f, totalWidthPx, 2 * cornerRadiusPx),
startAngleDegrees = 270f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(totalWidthPx, totalHeightPx - cornerRadiusPx)
arcTo(
rect = Rect(totalWidthPx - 2 * cornerRadiusPx, totalHeightPx - 2 * cornerRadiusPx, totalWidthPx, totalHeightPx),
startAngleDegrees = 0f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(cornerRadiusPx, totalHeightPx)
arcTo(
rect = Rect(0f, totalHeightPx - 2 * cornerRadiusPx, 2 * cornerRadiusPx, totalHeightPx),
startAngleDegrees = 90f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(0f, cornerRadiusPx)
arcTo(
rect = Rect(0f, 0f, 2 * cornerRadiusPx, 2 * cornerRadiusPx),
startAngleDegrees = 180f,
sweepAngleDegrees = 90f,
forceMoveTo = false
)
lineTo(startXPx, 0f)
close()
}
val blueGradient = Brush.linearGradient(
colors = listOf(
Color(0xFF00E5FF), // Bright Cyan
Color(0xFF0091FF), // Electric Sky Blue
Color(0xFF0055FF), // Deep Vivid Blue
Color(0xFF00D2FF) // Cyan-Blue
)
)
val failureGradient = Brush.linearGradient(
colors = listOf(
Color(0xFFFF3B30), // Bright Red
Color(0xFFCC0000), // Dark Red
Color(0xFF8B0000), // Very Dark Crimson
Color(0xFFFF3B30) // Bright Red
)
)
// 4a. If downloading or failed: Clockwise perimeter sweep starting above Home icon
if (currentDlProgress > 0f && currentGlow == 0f) {
val pathMeasure = PathMeasure()
pathMeasure.setPath(fullPath, false)
val totalLen = pathMeasure.length
val segmentPath = Path()
pathMeasure.getSegment(0f, totalLen * currentDlProgress.coerceIn(0f, 1f), segmentPath, true)
drawPath(
path = segmentPath,
brush = if (isDownloadFailed) failureGradient else blueGradient,
style = Stroke(
width = 3.dp.toPx(),
cap = StrokeCap.Round
)
)
}
// 4b. If download completed: 2x glowing outer halo + luminous neon perimeter rim
if (currentGlow > 0f) {
// Outer diffused glow halo
drawPath(
path = fullPath,
color = Color(0xFF00D2FF).copy(alpha = 0.6f * currentGlow),
style = Stroke(
width = (4.dp + 5.dp * currentGlow).toPx(),
cap = StrokeCap.Round
)
)
// Core luminous neon rim
drawPath(
path = fullPath,
brush = Brush.linearGradient(
colors = listOf(
Color(0xFF80EEFF).copy(alpha = currentGlow),
Color(0xFF00B4FF).copy(alpha = currentGlow),
Color(0xFF0066FF).copy(alpha = currentGlow),
Color(0xFF80EEFF).copy(alpha = currentGlow)
)
),
style = Stroke(
width = 3.2.dp.toPx(),
cap = StrokeCap.Round
)
)
}
}
}
}
}
}
@@ -11,6 +11,8 @@ import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import android.content.pm.ServiceInfo
import com.ujwal.focusgram.FocusGramApp
import com.ujwal.focusgram.MainActivity
import com.ujwal.focusgram.R
@@ -45,6 +47,27 @@ class ScreenTimeService : Service() {
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...")
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
initialNotification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
)
} else {
startForeground(NOTIFICATION_ID, initialNotification)
}
} catch (e: Exception) {
Log.e(TAG, "Primary startForeground failed in onCreate: ${e.message}", e)
try {
startForeground(NOTIFICATION_ID, initialNotification)
} catch (fallbackEx: Exception) {
Log.e(TAG, "Fallback startForeground failed: ${fallbackEx.message}", fallbackEx)
stopSelf()
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -68,6 +91,14 @@ class ScreenTimeService : Service() {
}
ACTION_STOP -> {
stopTracking()
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
} catch (_: Exception) {}
stopSelf()
}
}
@@ -80,8 +111,7 @@ class ScreenTimeService : Service() {
sessionStartTime = System.currentTimeMillis()
sessionSeconds = 0
val initialNotification = buildNotification("Focus Session Active", "Tracking active session time...")
startForeground(NOTIFICATION_ID, initialNotification)
updateNotification("Focus Session Active", "Tracking active session time...")
trackingJob = serviceScope.launch {
val app = FocusGramApp.instance
@@ -192,7 +222,7 @@ class ScreenTimeService : Service() {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(content)
.setSmallIcon(R.mipmap.ic_launcher)
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
@@ -201,8 +231,12 @@ class ScreenTimeService : Service() {
}
private fun updateNotification(title: String, content: String) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(title, content))
try {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(NOTIFICATION_ID, buildNotification(title, content))
} catch (e: Exception) {
Log.w(TAG, "Failed to update notification: ${e.message}")
}
}
override fun onDestroy() {
@@ -224,14 +258,18 @@ class ScreenTimeService : Service() {
const val EXTRA_IS_REELS = "extra_is_reels"
fun startSession(context: Context, isReels: Boolean = false) {
val intent = Intent(context, ScreenTimeService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_IS_REELS, isReels)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
try {
val intent = Intent(context, ScreenTimeService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_IS_REELS, isReels)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to startSession: ${e.message}", e)
}
}
@@ -28,16 +28,19 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ujwal.focusgram.core.ui.theme.PureBlack
import com.ujwal.focusgram.core.utils.HapticHelper
/**
* SessionExhaustedScreen
@@ -54,6 +57,13 @@ fun SessionExhaustedScreen(
onWatchAdToExtend: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
// Trigger physical session completion vibration alert (double buzz)
LaunchedEffect(Unit) {
HapticHelper.performSessionCompletionAlert(context)
}
// Lock down back navigation: back button will exit the app, never return to webview
BackHandler(enabled = true) {
onExit()
@@ -2,6 +2,11 @@ package com.ujwal.focusgram.feature.settings
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import com.ujwal.focusgram.R
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.BorderStroke
@@ -49,6 +54,7 @@ import androidx.compose.material.icons.filled.Security
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.ShoppingBag
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StayCurrentPortrait
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
@@ -126,9 +132,12 @@ fun SettingsScreen(
onNavigateScreenTime: () -> Unit,
onNavigateAppLock: () -> Unit,
onWatchAd: () -> Unit = {},
onWatchAdWithReward: (((suspend () -> Unit) -> Unit))? = null,
onNavigateUrl: (String) -> Unit = {},
screenTimeDao: ScreenTimeDao? = null,
reelHistory: ReelHistory? = null,
currentSubpage: SettingsSubpage = SettingsSubpage.MAIN,
onSubpageChange: (SettingsSubpage) -> Unit = {},
modifier: Modifier = Modifier
) {
val settings by repository.settingsFlow.collectAsState(initial = FocusSettings())
@@ -136,14 +145,28 @@ fun SettingsScreen(
val context = LocalContext.current
val mainListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
var currentSubpage by remember { mutableStateOf(SettingsSubpage.MAIN) }
var activeSubpage by remember(currentSubpage) { mutableStateOf(currentSubpage) }
val setSubpage: (SettingsSubpage) -> Unit = { page ->
activeSubpage = page
onSubpageChange(page)
}
var showLegalDialog by remember { mutableStateOf(false) }
var showDeveloperDialog by remember { mutableStateOf(false) }
var pendingRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) }
var adDialogReason by remember { mutableStateOf<String?>(null) }
var showSupportAdDialog by remember { mutableStateOf(false) }
val onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { action, reason ->
pendingRewardAction = action
adDialogReason = reason
showSupportAdDialog = true
}
// Intercept back presses: go back to MAIN subpage first, or pop screen
BackHandler {
if (currentSubpage != SettingsSubpage.MAIN) {
currentSubpage = SettingsSubpage.MAIN
if (activeSubpage != SettingsSubpage.MAIN) {
setSubpage(SettingsSubpage.MAIN)
} else {
onBack()
}
@@ -271,6 +294,78 @@ fun SettingsScreen(
)
}
// Support Ad Dialog
if (showSupportAdDialog && adDialogReason != null) {
AlertDialog(
onDismissRequest = {
showSupportAdDialog = false
pendingRewardAction = null
},
containerColor = Color(0xFF1C1C1E),
title = {
Text(
text = "Support FocusGram ❤️",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = adDialogReason ?: "Please watch an ad in order to toggle this setting.",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 14.sp
)
Text(
text = "Your 20 seconds can provide a huge contribution to the continuous development and improvement of FocusGram.\n\nThank you for supporting this project! ❤️",
color = Color(0xFF8E8E93),
fontSize = 13.sp,
lineHeight = 18.sp
)
}
},
confirmButton = {
Button(
onClick = {
showSupportAdDialog = false
val action = pendingRewardAction
pendingRewardAction = null
if (action != null) {
if (onWatchAdWithReward != null) {
onWatchAdWithReward(action)
} else {
scope.launch { action() }
onWatchAd()
}
}
},
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
),
shape = RoundedCornerShape(20.dp)
) {
Text("Watch Ad & Continue", fontWeight = FontWeight.Bold)
}
},
dismissButton = {
OutlinedButton(
onClick = {
showSupportAdDialog = false
pendingRewardAction = null
},
border = BorderStroke(1.dp, Color(0xFF3A3A3C)),
shape = RoundedCornerShape(20.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color.White)
) {
Text("Cancel")
}
}
)
}
Column(
modifier = modifier
.fillMaxSize()
@@ -285,8 +380,8 @@ fun SettingsScreen(
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = {
if (currentSubpage != SettingsSubpage.MAIN) {
currentSubpage = SettingsSubpage.MAIN
if (activeSubpage != SettingsSubpage.MAIN) {
setSubpage(SettingsSubpage.MAIN)
} else {
onBack()
}
@@ -298,7 +393,7 @@ fun SettingsScreen(
)
}
Text(
text = when (currentSubpage) {
text = when (activeSubpage) {
SettingsSubpage.MAIN -> "Settings and activity"
SettingsSubpage.FOCUS -> "Focus Mode"
SettingsSubpage.GUARDRAILS -> "Time Control & Guardrails"
@@ -316,12 +411,12 @@ fun SettingsScreen(
}
// Subpage Router
when (currentSubpage) {
when (activeSubpage) {
SettingsSubpage.MAIN -> {
MainSettingsContent(
settings = settings,
listState = mainListState,
onNavigateSubpage = { currentSubpage = it },
onNavigateSubpage = { setSubpage(it) },
onNavigateAppLock = onNavigateAppLock,
onNavigateScreenTime = onNavigateScreenTime,
onNavigateReelsHistory = onNavigateReelsHistory,
@@ -333,7 +428,11 @@ fun SettingsScreen(
)
}
SettingsSubpage.FOCUS -> {
FocusModeSubpage(repository = repository, settings = settings)
FocusModeSubpage(
repository = repository,
settings = settings,
onRequestAdReward = onRequestAdReward
)
}
SettingsSubpage.GUARDRAILS -> {
GuardrailsSubpage(
@@ -347,6 +446,7 @@ fun SettingsScreen(
ExtrasSubpage(
repository = repository,
settings = settings,
onRequestAdReward = onRequestAdReward,
onNavigateUrl = { url ->
onNavigateUrl(url)
onBack()
@@ -585,7 +685,7 @@ private fun MainSettingsContent(
IOSGroupedCard {
IOSSettingsRow(
title = "Extras",
subtitle = "Startup launch page, download media",
subtitle = "Startup launch page, media downloader",
icon = Icons.Default.Star,
iconBgColor = IOSBlue,
onClick = { onNavigateSubpage(SettingsSubpage.EXTRAS) },
@@ -713,7 +813,8 @@ private fun MainSettingsContent(
@Composable
private fun FocusModeSubpage(
repository: SettingsRepository,
settings: FocusSettings
settings: FocusSettings,
onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> }
) {
val scope = rememberCoroutineScope()
@@ -887,7 +988,16 @@ private fun FocusModeSubpage(
icon = Icons.Default.Block,
iconBgColor = IOSGreen,
checked = settings.noAds,
onCheckedChange = { scope.launch { repository.setNoAds(it) } },
onCheckedChange = { willEnable ->
if (willEnable) {
onRequestAdReward(
{ repository.setNoAds(true) },
"Please watch an ad in order to re-enable Ad Blocking."
)
} else {
scope.launch { repository.setNoAds(false) }
}
},
showDivider = false
)
}
@@ -1347,9 +1457,11 @@ private fun GhostModeSubpage(
private fun ExtrasSubpage(
repository: SettingsRepository,
settings: FocusSettings,
onRequestAdReward: (action: suspend () -> Unit, reason: String) -> Unit = { _, _ -> },
onNavigateUrl: (String) -> Unit = {}
) {
val scope = rememberCoroutineScope()
var showLaunchPageModal by remember { mutableStateOf(false) }
LazyColumn(
modifier = Modifier
@@ -1360,56 +1472,38 @@ private fun ExtrasSubpage(
item {
IOSSectionTitle(title = "STARTUP")
IOSGroupedCard {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "Launch Page",
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Choose which page opens when you launch FocusGram.",
color = Color(0xFF8E8E93),
fontSize = 13.sp
)
Spacer(modifier = Modifier.height(14.dp))
val pages = listOf(
"home" to "Home Feed",
"following" to "Following Feed",
"favorites" to "Favorites Feed",
"direct" to "Direct Messages"
)
pages.forEach { (key, label) ->
val isSelected = settings.startupPage == key
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (isSelected) Color.White.copy(alpha = 0.1f) else Color.Transparent)
.clickable { scope.launch { repository.setStartupPage(key) } }
.padding(horizontal = 12.dp, vertical = 10.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
color = if (isSelected) Color.White else Color(0xFF8E8E93),
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal,
fontSize = 14.sp
)
if (isSelected) {
Text("✓", color = Color.White, fontWeight = FontWeight.Bold)
val isCustomLaunch = settings.startupPage != "home"
IOSSettingsRow(
title = "Launch Page",
subtitle = if (isCustomLaunch) "Start on ${when (settings.startupPage) { "following" -> "Following Feed"; "favorites" -> "Favorites Feed"; "direct" -> "Direct Messages"; else -> "Home Feed" }}" else "Opens default Instagram Home",
icon = Icons.AutoMirrored.Filled.OpenInNew,
iconBgColor = IOSBlue,
checked = isCustomLaunch,
onCheckedChange = { isChecked ->
scope.launch {
if (isChecked) {
repository.setStartupPage("following")
showLaunchPageModal = true
} else {
repository.setStartupPage("home")
}
}
}
},
showDivider = isCustomLaunch
)
if (isCustomLaunch) {
IOSSettingsRow(
title = "Selected Target",
subtitle = "Choose screen to open upon launch",
trailingText = when (settings.startupPage) {
"following" -> "Following Feed"
"favorites" -> "Favorites Feed"
"direct" -> "Direct Messages"
else -> "Home Feed"
},
onClick = { showLaunchPageModal = true },
showDivider = false
)
}
}
}
@@ -1419,12 +1513,38 @@ private fun ExtrasSubpage(
IOSSectionTitle(title = "MEDIA")
IOSGroupedCard {
IOSSettingsRow(
title = "Download Media (Feed + Reels)",
subtitle = "Adds a download option on posts and reels",
title = "Media Downloader",
subtitle = "Download media from Feed, Reels, and Profiles",
icon = Icons.Default.Star,
iconBgColor = IOSOrange,
checked = settings.videoDownloadEnabled,
onCheckedChange = { scope.launch { repository.setVideoDownloadEnabled(it) } },
onCheckedChange = { newState ->
val actionDesc = if (newState) "enable" else "disable"
onRequestAdReward(
{ repository.setVideoDownloadEnabled(newState) },
"Please watch an ad in order to $actionDesc the Media Downloader."
)
},
showDivider = false
)
}
}
// DISPLAY
item {
IOSSectionTitle(title = "DISPLAY")
IOSGroupedCard {
IOSSettingsRow(
title = "Portrait Only",
subtitle = "Lock app orientation to portrait mode",
icon = Icons.Default.StayCurrentPortrait,
iconBgColor = IOSBlue,
checked = settings.portraitOnly,
onCheckedChange = { isChecked ->
scope.launch {
repository.setPortraitOnly(isChecked)
}
},
showDivider = false
)
}
@@ -1451,6 +1571,31 @@ private fun ExtrasSubpage(
Spacer(modifier = Modifier.height(40.dp))
}
}
if (showLaunchPageModal) {
val launchPages = remember {
listOf(
"home" to "Home Feed",
"following" to "Following Feed",
"favorites" to "Favorites Feed",
"direct" to "Direct Messages"
)
}
val currentIdx = remember(settings.startupPage) {
launchPages.indexOfFirst { it.first == settings.startupPage }.coerceAtLeast(0)
}
WheelPickerModalDialog(
title = "Launch Page",
subtitle = "Choose which page opens when you launch FocusGram",
options = launchPages,
initialIndex = currentIdx,
onDismiss = { showLaunchPageModal = false },
onConfirm = { option ->
scope.launch { repository.setStartupPage(option.first) }
},
labelProvider = { it.second }
)
}
}
// ─── APPEARANCE SUBPAGE ─────────────────────────────────────────────────────
@@ -1496,6 +1641,26 @@ private fun NotificationsSubpage(
settings: FocusSettings
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { _ -> }
val checkAndRequestNotificationPermission = { onGranted: () -> Unit ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
permissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
onGranted()
}
} else {
onGranted()
}
}
LazyColumn(
modifier = Modifier
@@ -1528,7 +1693,16 @@ private fun NotificationsSubpage(
icon = Icons.Default.Notifications,
iconBgColor = IOSTeal,
checked = settings.notifySessionEnd,
onCheckedChange = { scope.launch { repository.setNotifySessionEnd(it) } }
onCheckedChange = { willEnable ->
if (willEnable) {
checkAndRequestNotificationPermission {
scope.launch { repository.setNotifySessionEnd(true) }
}
scope.launch { repository.setNotifySessionEnd(true) }
} else {
scope.launch { repository.setNotifySessionEnd(false) }
}
}
)
IOSSettingsRow(
title = "Persistent Status Notification",
@@ -1536,7 +1710,16 @@ private fun NotificationsSubpage(
icon = Icons.Default.Timer,
iconBgColor = IOSBlue,
checked = settings.notifyPersistent,
onCheckedChange = { scope.launch { repository.setNotifyPersistent(it) } },
onCheckedChange = { willEnable ->
if (willEnable) {
checkAndRequestNotificationPermission {
scope.launch { repository.setNotifyPersistent(true) }
}
scope.launch { repository.setNotifyPersistent(true) }
} else {
scope.launch { repository.setNotifyPersistent(false) }
}
},
showDivider = false
)
}
@@ -157,9 +157,10 @@ fun AdsterraAdScreen(
var adsClicked by remember { mutableIntStateOf(0) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
val isSettingToggle = sessionType == "setting_toggle"
val timerDone = elapsedSeconds >= requiredSeconds
val bothClicked = adsClicked >= 2
val isComplete = timerDone && bothClicked
val isComplete = if (isSettingToggle) timerDone else (timerDone && bothClicked)
// 20s Timer Loop - restarts cleanly on timerCycle increment
LaunchedEffect(timerCycle) {
@@ -171,7 +172,8 @@ fun AdsterraAdScreen(
}
val statusText = when {
isComplete -> "Reward Unlocked! Tap below to claim focus minutes."
isComplete -> if (isSettingToggle) "Ad viewed! Tap below to apply your setting." else "Reward Unlocked! Tap below to claim focus minutes."
isSettingToggle -> "Viewing ad (${requiredSeconds - elapsedSeconds}s remaining)..."
bothClicked -> "Both ads clicked! Waiting for countdown (${requiredSeconds - elapsedSeconds}s)..."
timerDone -> "Timer completed. Please tap both ads ($adsClicked/2) to continue."
else -> "Tap BOTH ads below to earn reward credits ($adsClicked/2 clicked)"
@@ -400,8 +402,13 @@ fun AdsterraAdScreen(
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(6.dp))
val buttonLabel = when {
!isComplete -> if (isSettingToggle) "Viewing Ad (${(requiredSeconds - elapsedSeconds).coerceAtLeast(0)}s)" else "Complete Ad Actions to Unlock"
isSettingToggle -> "Continue & Apply Setting"
else -> "Continue & Earn Reward (+2 min)"
}
Text(
text = if (isComplete) "Continue & Earn Reward (+2 min)" else "Complete Ad Actions to Unlock",
text = buttonLabel,
fontWeight = FontWeight.Bold,
fontSize = 15.sp
)
@@ -35,6 +35,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -42,6 +43,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.ujwal.focusgram.core.ui.theme.InstagramBlue
import com.ujwal.focusgram.core.ui.theme.PureBlack
import com.ujwal.focusgram.core.utils.HapticHelper
import kotlinx.coroutines.delay
/**
@@ -54,6 +56,7 @@ fun BreathGateScreen(
durationSeconds: Int = 10,
onFinish: () -> Unit
) {
val context = LocalContext.current
val initialSeconds = durationSeconds.coerceIn(3, 60)
var secondsRemaining by remember { mutableIntStateOf(initialSeconds) }
val canContinue = secondsRemaining <= 0
@@ -64,6 +67,7 @@ fun BreathGateScreen(
delay(1000L)
secondsRemaining--
}
HapticHelper.performSuccessPulse(context)
}
// Mindful Breathing Expansion/Contraction Animation
@@ -34,6 +34,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -43,6 +44,7 @@ import com.ujwal.focusgram.core.ui.theme.DarkCard
import com.ujwal.focusgram.core.ui.theme.PureBlack
import com.ujwal.focusgram.core.ui.theme.TextMuted
import com.ujwal.focusgram.core.ui.theme.TextSecondary
import com.ujwal.focusgram.core.utils.HapticHelper
import kotlinx.coroutines.delay
/**
@@ -55,6 +57,7 @@ fun CooldownGateScreen(
onCooldownExpired: () -> Unit,
onExit: () -> Unit
) {
val context = LocalContext.current
var remainingSeconds by remember { mutableIntStateOf(remainingSecondsInitial.coerceAtLeast(0)) }
val quotes = remember {
@@ -71,6 +74,7 @@ fun CooldownGateScreen(
LaunchedEffect(remainingSeconds) {
if (remainingSeconds <= 0) {
HapticHelper.performSuccessPulse(context)
onCooldownExpired()
} else {
delay(1000L)
@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
@@ -18,13 +19,21 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.Build
import android.provider.MediaStore
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import java.io.File
import java.util.concurrent.atomic.AtomicReference
import com.ujwal.focusgram.MainActivity
import com.ujwal.focusgram.R
import com.ujwal.focusgram.core.utils.HapticHelper
import com.ujwal.focusgram.core.utils.MediaDownloadHelper
import com.ujwal.focusgram.feature.screentime.ScreenTimeService
import androidx.compose.ui.draw.blur
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -52,6 +61,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -122,7 +132,14 @@ enum class AppDestination {
@Composable
fun FocusGramApp() {
val context = LocalContext.current
val activity = context as? Activity
val activity = remember(context) {
var ctx = context
while (ctx is ContextWrapper) {
if (ctx is Activity) break
ctx = ctx.baseContext
}
ctx as? Activity
}
val lifecycleOwner = LocalLifecycleOwner.current
val scope = rememberCoroutineScope()
@@ -177,6 +194,8 @@ fun FocusGramApp() {
var isWebInputFocused by remember { mutableStateOf(false) }
var isModalActive by remember { mutableStateOf(false) }
var isUserAuthenticated by remember { mutableStateOf(false) }
var isAuthPage by remember { mutableStateOf(false) }
// Session intention countdown timer state
var sessionDurationMinutes by remember { mutableIntStateOf(15) }
@@ -184,6 +203,8 @@ fun FocusGramApp() {
var totalElapsedSeconds by remember { mutableIntStateOf(0) }
var adExtensionsUsed by remember { mutableIntStateOf(0) }
var isSessionExhausted by remember { mutableStateOf(false) }
var adRewardAction by remember { mutableStateOf<(suspend () -> Unit)?>(null) }
var settingsSubpage by rememberSaveable { mutableStateOf(com.ujwal.focusgram.feature.settings.SettingsSubpage.MAIN) }
// Evaluates whether any barrier, lock, breath pause, effort friction, cooldown, or intention picker is active
val isGateActive = shouldShowLock ||
@@ -193,59 +214,118 @@ fun FocusGramApp() {
(settings.effortFrictionEnabled && settings.wordChallengeCount > 0 && !hasPassedEffortFriction) ||
!hasPassedSessionPicker
// System File Chooser / Gallery Launchers for Post & Story uploads
var pendingFilePickerCallback by remember { mutableStateOf<((List<Uri>) -> Unit)?>(null) }
// System File Chooser / Camera / Gallery Launchers for Story, Post, DMs & Profile Uploads
val pendingFilePickerCallback = remember { AtomicReference<((List<Uri>) -> Unit)?>(null) }
val pendingCameraCaptureUri = remember { AtomicReference<Uri?>(null) }
val filePickerLauncher = rememberLauncherForActivityResult(
// 1. Android Modern Photo & Video Pickers (Official AndroidX contracts, zero permissions needed)
val singlePhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
val callback = pendingFilePickerCallback.getAndSet(null)
Log.i("FocusGramApp", "Single PhotoPicker result: $uri")
callback?.invoke(if (uri != null) listOf(uri) else emptyList())
}
val multiPhotoPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia()
) { uris ->
val callback = pendingFilePickerCallback.getAndSet(null)
Log.i("FocusGramApp", "Multi PhotoPicker result: ${uris.size} URIs: ${uris.joinToString()}")
callback?.invoke(uris)
}
// 2. Direct Camera Capture Launcher (Used when HTML input requests camera capture)
val takePictureLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
val callback = pendingFilePickerCallback.getAndSet(null)
val uri = pendingCameraCaptureUri.getAndSet(null)
Log.i("FocusGramApp", "TakePicture result: success=$success, uri=$uri")
if (success && uri != null) {
callback?.invoke(listOf(uri))
} else {
callback?.invoke(emptyList())
}
}
// 3. System Document/Gallery Fallback Launcher
val fallbackFilePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val uris = mutableListOf<Uri>()
val data = result.data
if (result.resultCode == android.app.Activity.RESULT_OK && data != null) {
val clipData = data.clipData
if (clipData != null) {
val callback = pendingFilePickerCallback.getAndSet(null)
if (result.resultCode == Activity.RESULT_OK) {
val data = result.data
val clipData = data?.clipData
val dataUri = data?.data
if (clipData != null && clipData.itemCount > 0) {
for (i in 0 until clipData.itemCount) {
clipData.getItemAt(i).uri?.let { uris.add(it) }
}
} else {
data.data?.let { uris.add(it) }
} else if (dataUri != null) {
uris.add(dataUri)
}
}
pendingFilePickerCallback?.invoke(uris)
pendingFilePickerCallback = null
Log.i("FocusGramApp", "Fallback file picker result code: ${result.resultCode}, selected: ${uris.size} URIs")
callback?.invoke(uris)
}
// Android Runtime Permissions Launcher (Camera, Audio, Storage)
var pendingPermissionCallback by remember { mutableStateOf<((Boolean) -> Unit)?>(null) }
val pendingPermissionCallback = remember { AtomicReference<((Boolean) -> Unit)?>(null) }
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { results ->
val allGranted = results.values.all { it }
pendingPermissionCallback?.invoke(allGranted)
pendingPermissionCallback = null
val callback = pendingPermissionCallback.getAndSet(null)
callback?.invoke(allGranted)
}
val filePickerHandler = remember {
object : FocusGramPromptDelegate.FilePickerHandler {
var launchChooser: ((Array<String>, Boolean) -> Unit)? = null
val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
Log.i("FocusGramApp", "Notification permission result: $isGranted")
}
override fun onPickFiles(
mimeTypes: Array<String>,
allowMultiple: Boolean,
onResult: (List<Uri>) -> Unit
) {
pendingFilePickerCallback = onResult
try {
launchChooser?.invoke(mimeTypes, allowMultiple) ?: onResult(emptyList())
} catch (e: Exception) {
Log.e("FocusGramApp", "Error launching file picker: ${e.message}", e)
onResult(emptyList())
LaunchedEffect(settings.notifyPersistent, settings.notifySessionEnd) {
if (settings.notifyPersistent || settings.notifySessionEnd) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (androidx.core.content.ContextCompat.checkSelfPermission(
context,
android.Manifest.permission.POST_NOTIFICATIONS
) != android.content.pm.PackageManager.PERMISSION_GRANTED
) {
notificationPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}
filePickerHandler.launchChooser = { mimes, allowMultiple ->
val filePickerHandler = remember {
object : FocusGramPromptDelegate.FilePickerHandler {
var launchChooser: ((Array<String>, Boolean, Int) -> Unit)? = null
override fun onPickFiles(
mimeTypes: Array<String>,
allowMultiple: Boolean,
capture: Int,
onResult: (List<Uri>) -> Unit
) {
pendingFilePickerCallback.set(onResult)
try {
launchChooser?.invoke(mimeTypes, allowMultiple, capture) ?: run {
Log.w("FocusGramApp", "launchChooser was null, returning empty list")
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
}
} catch (e: Exception) {
Log.e("FocusGramApp", "Error invoking launchChooser: ${e.message}", e)
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
}
}
}
}
filePickerHandler.launchChooser = { mimes, allowMultiple, capture ->
val mimeMap = android.webkit.MimeTypeMap.getSingleton()
val cleanMimes = mimes
.flatMap { it.split(",") }
@@ -263,35 +343,120 @@ fun FocusGramApp() {
.distinct()
.toTypedArray()
val hasImages = cleanMimes.any { it.startsWith("image/") } || cleanMimes.isEmpty()
val hasVideos = cleanMimes.any { it.startsWith("video/") }
Log.i("FocusGramApp", "Launching media picker for cleanMimes: ${cleanMimes.joinToString()}, allowMultiple: $allowMultiple, capture: $capture")
val intent = android.content.Intent(android.content.Intent.ACTION_GET_CONTENT).apply {
addCategory(android.content.Intent.CATEGORY_OPENABLE)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (allowMultiple) {
putExtra(android.content.Intent.EXTRA_ALLOW_MULTIPLE, true)
}
if (hasImages && !hasVideos) {
type = "image/*"
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) {
putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes)
}
} else if (hasVideos && !hasImages) {
type = "video/*"
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) {
putExtra(android.content.Intent.EXTRA_MIME_TYPES, cleanMimes)
}
} else {
type = "*/*"
val fallbackTypes = if (cleanMimes.isNotEmpty()) cleanMimes else arrayOf("image/*", "video/*")
putExtra(android.content.Intent.EXTRA_MIME_TYPES, fallbackTypes)
val isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*")
val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") }
val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") }
val currentUrlLower = sessionManager.currentUrl.value.lowercase()
val isProfilePictureEdit = currentUrlLower.contains("/accounts/edit")
// In Instagram, DMs (/direct/), Stories, and Feed Posts all support videos (MP4, MOV, WebM).
// Unless explicitly updating an account avatar/profile picture, always allow both Photos and Videos.
val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages)
val allowsImages = hasImages || isAnyMedia
var launched = false
// 1. Direct Camera Capture Mode (Explicitly requested by web page via capture attribute)
if (capture != org.mozilla.geckoview.GeckoSession.PromptDelegate.FilePrompt.Capture.NONE) {
try {
val cameraDir = File(context.cacheDir, "camera").apply { mkdirs() }
val photoFile = File(cameraDir, "camera_capture.jpg")
if (photoFile.exists()) photoFile.delete()
val photoUri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile
)
pendingCameraCaptureUri.set(photoUri)
takePictureLauncher.launch(photoUri)
launched = true
} catch (eCamera: Exception) {
Log.w("FocusGramApp", "Direct camera launch failed: ${eCamera.message}, trying photo picker", eCamera)
}
}
val chooser = android.content.Intent.createChooser(intent, "Select Media").apply {
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
// 2. Primary Media Picker: Modern Android Photo & Video Picker (PickVisualMedia)
// Works seamlessly on Android 11+ and Samsung Galaxy OneUI without any permissions
if (!launched) {
try {
val visualMediaType = when {
allowsImages && !allowsVideos -> ActivityResultContracts.PickVisualMedia.ImageOnly
allowsVideos && !allowsImages -> ActivityResultContracts.PickVisualMedia.VideoOnly
else -> ActivityResultContracts.PickVisualMedia.ImageAndVideo
}
val request = PickVisualMediaRequest.Builder()
.setMediaType(visualMediaType)
.build()
if (allowMultiple) {
multiPhotoPickerLauncher.launch(request)
} else {
singlePhotoPickerLauncher.launch(request)
}
launched = true
} catch (ePhotoPicker: Exception) {
Log.w("FocusGramApp", "PickVisualMedia failed: ${ePhotoPicker.message}, attempting ACTION_GET_CONTENT", ePhotoPicker)
}
}
// 3. Fallback 1: ACTION_GET_CONTENT (with CATEGORY_OPENABLE for Samsung & AOSP DocumentsUI)
if (!launched) {
try {
val getContentIntent = Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
if (allowMultiple) {
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
if (allowsImages && !allowsVideos) {
type = "image/*"
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("image/*")) {
putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes)
}
} else if (allowsVideos && !allowsImages) {
type = "video/*"
if (cleanMimes.isNotEmpty() && !cleanMimes.contains("video/*")) {
putExtra(Intent.EXTRA_MIME_TYPES, cleanMimes)
}
} else {
type = "*/*"
val fallbackTypes = if (cleanMimes.isNotEmpty() && allowsVideos && !hasVideos) {
(cleanMimes.toList() + listOf("video/*", "video/mp4")).toTypedArray()
} else if (cleanMimes.isNotEmpty()) {
cleanMimes
} else {
arrayOf("image/*", "video/*")
}
putExtra(Intent.EXTRA_MIME_TYPES, fallbackTypes)
}
}
fallbackFilePickerLauncher.launch(getContentIntent)
launched = true
} catch (eGetContent: Exception) {
Log.w("FocusGramApp", "ACTION_GET_CONTENT failed: ${eGetContent.message}, attempting ACTION_PICK", eGetContent)
}
}
// 4. Fallback 2: ACTION_PICK from MediaStore
if (!launched) {
try {
val pickIntent = if (allowsVideos && !allowsImages) {
Intent(Intent.ACTION_PICK, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
} else {
Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
}.apply {
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
fallbackFilePickerLauncher.launch(pickIntent)
launched = true
} catch (ePick: Exception) {
Log.e("FocusGramApp", "All file picker fallbacks failed: ${ePick.message}", ePick)
pendingFilePickerCallback.getAndSet(null)?.invoke(emptyList())
}
}
filePickerLauncher.launch(chooser)
}
// Pre-warm Rewarded Ad container and cache in background (isolated from GeckoView)
@@ -343,6 +508,7 @@ fun FocusGramApp() {
if (totalTodaySeconds >= settings.dailyLimitMinutes * 60) {
isSessionExhausted = true
settingsRepository.recordSessionEnd()
HapticHelper.performSessionCompletionAlert(context)
break
}
}
@@ -351,6 +517,7 @@ fun FocusGramApp() {
if (sessionRemainingSeconds <= 0) {
isSessionExhausted = true
settingsRepository.recordSessionEnd()
HapticHelper.performSessionCompletionAlert(context)
if (settings.notifySessionEnd) {
postSessionExpiredNotification(context)
}
@@ -480,9 +647,20 @@ fun FocusGramApp() {
}
}
}
extensionManager.onDownloadStarted = { filename ->
MediaDownloadHelper.startIndeterminateProgress(filename)
}
extensionManager.onDownloadRequested = { url, filename ->
MediaDownloadHelper.download(context, url, filename)
}
extensionManager.onDownloadError = { error ->
MediaDownloadHelper.triggerDownloadFailure(context, error)
}
extensionManager.onAuthStateChanged = { authenticated, authPage ->
isUserAuthenticated = authenticated
isAuthPage = authPage
sessionManager.setAuthenticated(authenticated)
}
}
LaunchedEffect(reelHistory, database) {
@@ -501,6 +679,10 @@ fun FocusGramApp() {
sessionManager = sessionManager,
onRouteChanged = { newUrl ->
val mode = IslandMode.fromUrl(newUrl)
val lower = newUrl.lowercase()
if (lower.contains("/accounts") || lower.contains("/login") || lower.contains("/signup")) {
isAuthPage = true
}
sessionManager.updateCurrentUrl(newUrl)
sessionManager.updateIslandMode(mode)
extensionManager.setIslandMode(mode)
@@ -517,7 +699,7 @@ fun FocusGramApp() {
sessionManager.session.permissionDelegate = FocusGramPermissionDelegate(
context = context,
onAndroidPermissionRequest = { perms, callback ->
pendingPermissionCallback = callback
pendingPermissionCallback.set(callback)
permissionLauncher.launch(perms)
}
)
@@ -562,6 +744,15 @@ fun FocusGramApp() {
previousReloadSettings = settings
}
// Dynamic screen orientation lock: enforce portrait-only when enabled
LaunchedEffect(settings.portraitOnly, activity) {
activity?.requestedOrientation = if (settings.portraitOnly) {
ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
} else {
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
}
}
// When any barrier, gate, lock, or intention picker is active, pressing back exits the app cleanly
BackHandler(enabled = isGateActive && destination != AppDestination.ADSTERRA_REWARDED) {
activity?.finish()
@@ -672,10 +863,11 @@ fun FocusGramApp() {
sessionManager.refreshReels()
},
onDownloadClick = {
MediaDownloadHelper.startIndeterminateProgress("Preparing Reel download...")
extensionManager.triggerReelDownload()
sessionManager.userScriptManager?.evaluate(
sessionManager.session,
"window.dispatchEvent(new CustomEvent('focusgram:download-reel'));"
"if (typeof window.__fgDownloadActiveReel === 'function') { window.__fgDownloadActiveReel(); } else { window.dispatchEvent(new CustomEvent('focusgram:download-reel', { bubbles: true })); }"
)
},
modifier = Modifier.align(Alignment.TopCenter)
@@ -688,6 +880,7 @@ fun FocusGramApp() {
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(navBarTotalHeight)
.blur(18.dp)
.background(
Brush.verticalGradient(
colors = if (isAppLight) {
@@ -712,6 +905,7 @@ fun FocusGramApp() {
// Native Bottom Navigation Bar (OLED floating island)
if (!isGateActive && destination == AppDestination.MAIN_WEBVIEW) {
val instagramTheme by sessionManager.instagramTheme.collectAsState()
val downloadState by MediaDownloadHelper.downloadProgressFlow.collectAsState()
NativeBottomNavBar(
currentUrl = currentUrl,
settings = settings,
@@ -719,6 +913,12 @@ fun FocusGramApp() {
instagramTheme = instagramTheme,
isWebInputFocused = isWebInputFocused,
isModalActive = isModalActive,
downloadProgress = downloadState.progress,
isDownloading = downloadState.isDownloading,
isDownloadCompleted = downloadState.isCompleted,
isDownloadFailed = downloadState.isFailed,
isAuthenticated = isUserAuthenticated,
isAuthPage = isAuthPage,
onNavClick = { target ->
extensionManager.go(target)
},
@@ -776,12 +976,18 @@ fun FocusGramApp() {
onNavigateScreenTime = { destination = AppDestination.SCREEN_TIME },
onNavigateAppLock = { destination = AppDestination.APP_LOCK_SETTINGS },
onWatchAd = { destination = AppDestination.ADSTERRA_REWARDED },
onWatchAdWithReward = { action ->
adRewardAction = action
destination = AppDestination.ADSTERRA_REWARDED
},
onNavigateUrl = { url ->
destination = AppDestination.MAIN_WEBVIEW
sessionManager.navigateTo(url)
},
screenTimeDao = database.screenTimeDao(),
reelHistory = reelHistory
reelHistory = reelHistory,
currentSubpage = settingsSubpage,
onSubpageChange = { settingsSubpage = it }
)
}
@@ -850,9 +1056,14 @@ fun FocusGramApp() {
AppDestination.ADSTERRA_REWARDED -> {
AdsterraAdScreen(
sessionType = if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary",
sessionType = if (adRewardAction != null) "setting_toggle" else if (isDailyLimitExhausted || isSessionExhausted) "session_extend" else "voluntary",
onRewardClaimed = {
if (isDailyLimitExhausted) {
val pendingAction = adRewardAction
adRewardAction = null
if (pendingAction != null) {
scope.launch { pendingAction.invoke() }
destination = AppDestination.SETTINGS
} else if (isDailyLimitExhausted) {
scope.launch { settingsRepository.extendDailyLimit5Minutes(today) }
sessionRemainingSeconds += 5 * 60
sessionDurationMinutes += 5
@@ -872,6 +1083,7 @@ fun FocusGramApp() {
}
},
onCancel = {
adRewardAction = null
if (isDailyLimitExhausted || isSessionExhausted) {
destination = AppDestination.MAIN_WEBVIEW
} else {
@@ -960,6 +1172,9 @@ fun FocusGramApp() {
private fun postSessionExpiredNotification(context: Context) {
try {
// Direct physical vibration ensures the user feels the alert regardless of notification channel mutes
HapticHelper.performSessionCompletionAlert(context)
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channelId = "focusgram_session_alerts"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
@@ -988,7 +1203,7 @@ private fun postSessionExpiredNotification(context: Context) {
val notification = NotificationCompat.Builder(context, channelId)
.setContentTitle("Focus Session Expired")
.setContentText("Your scheduled time limit has finished. Take a breath and step away.")
.setSmallIcon(R.mipmap.ic_launcher)
.setSmallIcon(R.drawable.ic_notification)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setDefaults(NotificationCompat.DEFAULT_ALL)
.setAutoCancel(true)
@@ -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="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07 -1.63,-5.64 -4.5,-6.32V4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="camera_photos" path="camera/" />
<external-cache-path name="ext_camera_photos" path="camera/" />
</paths>
@@ -108,6 +108,21 @@ class FocusFeaturesEnhancementsTest {
assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)"))
}
@Test
fun testHapticHelperImplementsUniversalDirectHardwareVibration() {
val source = getFile("src/main/kotlin/com/ujwal/focusgram/core/utils/HapticHelper.kt").readText()
assertFalse("Must not use deprecated Context.VIBRATOR_SERVICE", source.contains("Context.VIBRATOR_SERVICE"))
assertTrue("Must use VibratorManager on API 31+", source.contains("VibratorManager::class.java"))
assertTrue("Must use Vibrator class on pre-31", source.contains("context.getSystemService(Vibrator::class.java)"))
assertTrue("Must pulse physical vibrator directly with >= 30ms duration to overcome ERM motor inertia",
source.contains("createOneShot(35L") || source.contains("createOneShot(30L"))
assertTrue("Must provide session completion waveform pattern", source.contains("createWaveform(pattern"))
val sessionExhaustedSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/session/SessionExhaustedScreen.kt").readText()
assertTrue("SessionExhaustedScreen must trigger session completion alert vibration",
sessionExhaustedSource.contains("HapticHelper.performSessionCompletionAlert"))
}
@Test
fun testAdsterraDirectContainerAndPreloader() {
val source = getFile("src/main/kotlin/com/ujwal/focusgram/feature/wellness/AdsterraAdScreen.kt").readText()
@@ -192,4 +207,281 @@ class FocusFeaturesEnhancementsTest {
assertTrue("Native bottom nav bar in minimal mode must only display MESSAGES and PROFILE",
navBarSource.contains("listOf(NavTab.MESSAGES, NavTab.PROFILE)"))
}
@Test
fun testFilePickerManifestAndProviderConfiguration() {
val manifestSource = getFile("src/main/AndroidManifest.xml").readText()
assertTrue("Manifest must declare FileProvider",
manifestSource.contains("androidx.core.content.FileProvider"))
assertTrue("Manifest must declare intent queries for PICK_IMAGES",
manifestSource.contains("android.provider.action.PICK_IMAGES"))
assertTrue("Manifest must declare intent queries for GET_CONTENT",
manifestSource.contains("android.intent.action.GET_CONTENT"))
assertTrue("Manifest must declare camera capture intents in queries",
manifestSource.contains("android.media.action.IMAGE_CAPTURE"))
val pathsSource = getFile("src/main/res/xml/file_paths.xml").readText()
assertTrue("file_paths.xml must configure cache-path for camera",
pathsSource.contains("cache-path name=\"camera_photos\" path=\"camera/\""))
}
@Test
fun testNativeBottomNavBarBlurAndGlassStyling() {
val navBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/NativeBottomNavBar.kt").readText()
assertTrue("NavBar must include frosted glass blur layer",
navBarSource.contains(".blur(20.dp)"))
assertTrue("NavBar must include glassBorderBrush",
navBarSource.contains("glassBorderBrush"))
assertTrue("NavBar must integrate HapticHelper for device-compatible vibrations",
navBarSource.contains("HapticHelper.performHapticFeedback") || navBarSource.contains("HapticHelper"))
}
@Test
fun testFragment186VersionEnforcement() {
val buildGradle = getFile("build.gradle.kts").readText()
assertTrue("build.gradle.kts must force androidx.fragment:fragment:1.8.6",
buildGradle.contains("androidx.fragment:fragment:1.8.6"))
assertTrue("build.gradle.kts must force androidx.fragment:fragment-ktx:1.8.6",
buildGradle.contains("androidx.fragment:fragment-ktx:1.8.6"))
val libsToml = getFile("../gradle/libs.versions.toml").readText()
assertTrue("libs.versions.toml must declare fragment = \"1.8.6\"",
libsToml.contains("fragment = \"1.8.6\""))
}
@Test
fun testFilePickerMimeParsingAndMediaRouting() {
fun resolveMediaTypes(mimes: Array<String>, isProfilePictureEdit: Boolean = false): Triple<Boolean, Boolean, String> {
val cleanMimes = mimes
.flatMap { it.split(",") }
.map { it.trim() }
.filter { it.isNotEmpty() }
.mapNotNull { token ->
if (token.startsWith(".jpg") || token.startsWith(".jpeg")) "image/jpeg"
else if (token.startsWith(".png")) "image/png"
else if (token.startsWith(".mp4")) "video/mp4"
else if (token.contains("/")) token
else null
}
.distinct()
.toTypedArray()
val isAnyMedia = cleanMimes.isEmpty() || cleanMimes.contains("*/*")
val hasImages = isAnyMedia || cleanMimes.any { it.startsWith("image/") }
val hasVideos = isAnyMedia || cleanMimes.any { it.startsWith("video/") }
val allowsVideos = hasVideos || (!isProfilePictureEdit && hasImages)
val allowsImages = hasImages || isAnyMedia
val visualMediaType = when {
allowsImages && !allowsVideos -> "ImageOnly"
allowsVideos && !allowsImages -> "VideoOnly"
else -> "ImageAndVideo"
}
return Triple(allowsImages, allowsVideos, visualMediaType)
}
// 1. Profile Picture: /accounts/edit screen strictly image only
val (profileImg, profileVid, profileType) = resolveMediaTypes(arrayOf("image/*"), isProfilePictureEdit = true)
assertTrue(profileImg)
assertFalse(profileVid)
assertEquals("ImageOnly", profileType)
// 2. DM / Story upload: Instagram sends only image MIME types, but we must allow both Images and Videos!
val (dmImg, dmVid, dmType) = resolveMediaTypes(arrayOf("image/avif, image/jpeg, image/png"), isProfilePictureEdit = false)
assertTrue(dmImg)
assertTrue(dmVid)
assertEquals("ImageAndVideo", dmType)
val (storyImg, storyVid, storyType) = resolveMediaTypes(arrayOf("image/png, image/jpeg"), isProfilePictureEdit = false)
assertTrue(storyImg)
assertTrue(storyVid)
assertEquals("ImageAndVideo", storyType)
// 3. Reels: Video only
val (reelsImg, reelsVid, reelsType) = resolveMediaTypes(arrayOf("video/mp4, video/quicktime"), isProfilePictureEdit = false)
assertFalse(reelsImg)
assertTrue(reelsVid)
assertEquals("VideoOnly", reelsType)
// 4. Fallback: Empty array or */*
val (emptyImg, emptyVid, emptyType) = resolveMediaTypes(emptyArray(), isProfilePictureEdit = false)
assertTrue(emptyImg)
assertTrue(emptyVid)
assertEquals("ImageAndVideo", emptyType)
val (starImg, starVid, starType) = resolveMediaTypes(arrayOf("*/*"), isProfilePictureEdit = false)
assertTrue(starImg)
assertTrue(starVid)
assertEquals("ImageAndVideo", starType)
}
@Test
fun testNativeFeelIncludesVideoSupportBridge() {
val script = getFile("src/main/assets/scripts/native_feel.js").readText()
assertTrue("native_feel.js must include enhanceFileInputsForVideoSupport",
script.contains("enhanceFileInputsForVideoSupport"))
assertTrue("native_feel.js must broaden accept attribute to include video formats",
script.contains("video/mp4,video/quicktime,video/webm,video/*"))
assertTrue("native_feel.js must exclude /accounts/edit from video patching",
script.contains("/accounts/edit"))
}
@Test
fun testReelsTopBarWatchedCount() {
val appSource = getFile("src/main/kotlin/com/ujwal/focusgram/ui/FocusGramApp.kt").readText()
assertTrue("FocusGramApp must pass reelsWatchedSessionCount directly to ReelsTopBar",
appSource.contains("watchedCount = reelsWatchedSessionCount"))
val topBarSource = getFile("src/main/kotlin/com/ujwal/focusgram/feature/navigation/ReelsTopBar.kt").readText()
assertTrue("ReelsTopBar must default watchedCount to 0",
topBarSource.contains("watchedCount: Int = 0"))
}
@Test
fun testDownloadProgressStateAndIndeterminateProgress() {
val defaultState = com.ujwal.focusgram.core.utils.DownloadProgressState()
assertFalse(defaultState.isDownloading)
assertFalse(defaultState.isCompleted)
assertEquals(0f, defaultState.progress, 0.001f)
com.ujwal.focusgram.core.utils.MediaDownloadHelper.startIndeterminateProgress("test_reel")
val activeState = com.ujwal.focusgram.core.utils.MediaDownloadHelper.downloadProgressFlow.value
assertTrue("isDownloading must be true after startIndeterminateProgress", activeState.isDownloading)
assertFalse("isCompleted must be false while downloading", activeState.isCompleted)
assertTrue("progress should be >= 0.05f", activeState.progress >= 0.05f)
assertEquals("test_reel", activeState.filename)
}
@Test
fun testHomeVsProfileTabResolutionWithQueryParams() {
// Simulates the exact logic in NativeBottomNavBar
fun getCleanPath(url: String): String {
val afterScheme = if (url.contains("://")) url.substringAfter("://") else url
val pathPart = if (afterScheme.contains('/')) afterScheme.substringAfter('/') else ""
return pathPart.substringBefore('?').substringBefore('#').trim('/')
}
fun resolveIsHome(url: String): Boolean {
val cleanPath = getCleanPath(url)
val lowerUrl = url.lowercase()
val isHome = cleanPath.isEmpty()
return isHome || lowerUrl.startsWith("https://www.instagram.com/?") || lowerUrl.startsWith("https://instagram.com/?") || lowerUrl == "/"
}
fun resolveIsProfile(url: String): Boolean {
val cleanPath = getCleanPath(url)
val lowerUrl = url.lowercase()
val isHome = cleanPath.isEmpty()
val isInstagram = lowerUrl.contains("instagram.com") || lowerUrl.startsWith("/")
val isExcluded = isHome ||
lowerUrl.contains("/explore") ||
lowerUrl.contains("/reel") ||
lowerUrl.contains("/direct") ||
lowerUrl.contains("/inbox") ||
lowerUrl.contains("/p/") ||
lowerUrl.contains("/stories/") ||
lowerUrl.startsWith("focusgram-download:") ||
lowerUrl.startsWith("javascript:")
val segments = cleanPath.trim('/').split('/').filter { it.isNotEmpty() }
val isSingleUserSegment = segments.size == 1
return !isHome && isInstagram && !isExcluded &&
(lowerUrl.contains("/profile") || isSingleUserSegment)
}
// Home URLs with parameters must always resolve to Home, NEVER Profile
assertTrue(resolveIsHome("https://www.instagram.com/"))
assertFalse(resolveIsProfile("https://www.instagram.com/"))
assertTrue(resolveIsHome("https://www.instagram.com/?variant=home"))
assertFalse(resolveIsProfile("https://www.instagram.com/?variant=home"))
assertTrue(resolveIsHome("https://www.instagram.com/?theme=dark"))
assertFalse(resolveIsProfile("https://www.instagram.com/?theme=dark"))
// Profile URLs
assertFalse(resolveIsHome("https://www.instagram.com/zuck/"))
assertTrue(resolveIsProfile("https://www.instagram.com/zuck/"))
assertFalse(resolveIsHome("https://www.instagram.com/profile"))
assertTrue(resolveIsProfile("https://www.instagram.com/profile"))
// Excluded pages (Explore, Reels, DMs, Posts)
assertFalse(resolveIsProfile("https://www.instagram.com/explore/"))
assertFalse(resolveIsProfile("https://www.instagram.com/reel/C7x90/"))
assertFalse(resolveIsProfile("https://www.instagram.com/p/C7x90/"))
assertFalse(resolveIsProfile("https://www.instagram.com/direct/inbox/"))
}
@Test
fun testFocusGramContentAntiFlashDoesNotCollapseFeed() {
val script = getFile("src/main/assets/extensions/ublock/focusgram_content.js").readText()
assertFalse(
"focusgram_content.js must NOT have overly broad div:has(> a[href='/']) that caused feed blanking",
script.contains("div:has(> a[href=\"/\"]):has(a[href*=\"/explore/\"])")
)
assertFalse(
"focusgram_content.js must NOT have div:has(> div > a[href='/'])",
script.contains("div:has(> div > a[href=\"/\"]):has(a[href*=\"/explore/\"])")
)
assertTrue(
"focusgram_content.js must restrict anti-flash hiding to navigation/footer roles",
script.contains("nav[role=\"navigation\"]:has(a[href*=\"/explore/\"])")
)
}
@Test
fun testMediaDownloaderDoesNotUseIframeToPreventCspViolations() {
val script = getFile("src/main/assets/scripts/media_downloader.js").readText()
assertFalse(
"media_downloader.js must not create iframes with custom schemes which violate Instagram frame-src CSP",
script.contains("document.createElement('iframe')")
)
}
@Test
fun testMediaDownloaderAccidentalScrollGuard() {
val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText()
assertTrue(
"media_downloader.js must guard against accidental download triggers during feed scrolling with hold timer",
scriptUser.contains("isGlobalScrolling") && scriptUser.contains("touchmove") && scriptUser.contains("holdThresholdPassed")
)
}
@Test
fun testMediaDownloaderPreventsFalseVideoHijackingOnPhotos() {
val scriptUser = getFile("src/main/assets/scripts/media_downloader.js").readText()
assertTrue(
"media_downloader.js must check hasVideoElement before inspecting video streams from performance timings",
scriptUser.contains("hasVideoElement")
)
}
@Test
fun testMediaDownloadHelperPhotoVsVideoDetectionAndFilename() {
val helper = com.ujwal.focusgram.core.utils.MediaDownloadHelper
val photoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t51.82787-15/841260402_18131269318760808_4130815482121662762_n.jpg?stp=dst-jpg_e15_tt6&_nc_cat=107"
val videoUrl = "https://instagram.fktm1-1.fna.fbcdn.net/v/t50.28834-16/123456_n.mp4"
assertFalse("Photo URL with /v/t51 and dst-jpg must NOT be detected as video", helper.detectIsVideo(photoUrl, "instagram_xyz.mp4"))
assertTrue("Video URL with /v/t50 and .mp4 must be detected as video", helper.detectIsVideo(videoUrl, "instagram_xyz.mp4"))
val formattedPhotoName = helper.formatTargetFilename("@ujwal - Focusgram.mp4", false)
assertEquals("@ujwal - Focusgram.jpg", formattedPhotoName)
val formattedVideoName = helper.formatTargetFilename("@ujwal - Focusgram.jpg", true)
assertEquals("@ujwal - Focusgram.mp4", formattedVideoName)
val fallbackFormatted = helper.formatTargetFilename("instagram_DehM0IhkVm0.jpg", false)
assertEquals("@Focusgram - Focusgram.jpg", fallbackFormatted)
val normalAuthorFormatted = helper.formatTargetFilename("@ujwal", true)
assertEquals("@ujwal - Focusgram.mp4", normalAuthorFormatted)
val longAuthorFormatted = helper.formatTargetFilename("ujwaldeveloper12345", true)
assertEquals("@ujwaldevelope - Focusgram.mp4", longAuthorFormatted)
}
}
@@ -37,6 +37,8 @@ class FocusSettingsTest {
// Initial user level
assertEquals(1, settings.userLevel)
assertEquals(0, settings.userXP)
// Portrait only enabled by default
assertTrue(settings.portraitOnly)
}
@Test
+2
View File
@@ -12,6 +12,7 @@ room = "2.6.1"
datastore = "1.1.2"
securityCrypto = "1.1.0-alpha06"
biometric = "1.2.0-alpha05"
fragment = "1.8.6"
geckoview = "157.0.20260924084938"
coroutines = "1.10.1"
junit = "4.13.2"
@@ -47,6 +48,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto
# Security & Biometrics
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
androidx-biometric = { group = "androidx.biometric", name = "biometric", version.ref = "biometric" }
androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }