diff --git a/__tests__/unit/hooks/useNotifRationale.test.ts b/__tests__/unit/hooks/useNotifRationale.test.ts
deleted file mode 100644
index e87293dff..000000000
--- a/__tests__/unit/hooks/useNotifRationale.test.ts
+++ /dev/null
@@ -1,116 +0,0 @@
-import { renderHook, act } from '@testing-library/react-native';
-import { Platform, PermissionsAndroid } from 'react-native';
-import { useNotifRationale } from '../../../src/screens/ModelsScreen/useNotifRationale';
-
-const mockRequestNotificationPermission = jest.fn().mockResolvedValue(undefined);
-jest.mock('../../../src/services', () => ({
- backgroundDownloadService: {
- get requestNotificationPermission() { return mockRequestNotificationPermission; },
- },
-}));
-
-jest.mock('../../../src/utils/logger', () => ({
- __esModule: true,
- default: { warn: jest.fn() },
-}));
-
-function setupAndroid33(permissionGranted: boolean) {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 33 });
- jest.spyOn(PermissionsAndroid, 'check').mockResolvedValue(permissionGranted);
-}
-
-async function renderAndTrigger(isFirstDownload: boolean) {
- const proceed = jest.fn();
- const hook = renderHook(() => useNotifRationale(isFirstDownload));
- await act(async () => {
- await hook.result.current.maybeShowNotifRationale(proceed);
- });
- return { ...hook, proceed };
-}
-
-describe('useNotifRationale', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('proceeds immediately when not first download', async () => {
- const { result, proceed } = await renderAndTrigger(false);
-
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-
- it('proceeds immediately on iOS', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
- const { result, proceed } = await renderAndTrigger(true);
-
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- });
-
- it('proceeds immediately on Android < 33', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 32 });
- const { result, proceed } = await renderAndTrigger(true);
-
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-
- it('proceeds immediately when permission already granted', async () => {
- setupAndroid33(true);
- const { result, proceed } = await renderAndTrigger(true);
-
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-
- it('shows rationale on Android 33+ first download without permission', async () => {
- setupAndroid33(false);
- const { result, proceed } = await renderAndTrigger(true);
-
- expect(proceed).not.toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(true);
- });
-
- it('handleNotifRationaleAllow requests permission then proceeds', async () => {
- setupAndroid33(false);
- const { result, proceed } = await renderAndTrigger(true);
- expect(result.current.showNotifRationale).toBe(true);
-
- await act(async () => { result.current.handleNotifRationaleAllow(); });
- await act(async () => { await Promise.resolve(); });
-
- expect(mockRequestNotificationPermission).toHaveBeenCalled();
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-
- it('only shows rationale once per session', async () => {
- setupAndroid33(false);
- const { result, proceed } = await renderAndTrigger(true);
- expect(proceed).not.toHaveBeenCalled();
-
- await act(async () => { result.current.handleNotifRationaleDismiss(); });
-
- const proceed2 = jest.fn();
- await act(async () => {
- await result.current.maybeShowNotifRationale(proceed2);
- });
- expect(proceed2).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-
- it('handleNotifRationaleDismiss proceeds without requesting permission', async () => {
- setupAndroid33(false);
- const { result, proceed } = await renderAndTrigger(true);
-
- await act(async () => { result.current.handleNotifRationaleDismiss(); });
-
- expect(mockRequestNotificationPermission).not.toHaveBeenCalled();
- expect(proceed).toHaveBeenCalled();
- expect(result.current.showNotifRationale).toBe(false);
- });
-});
diff --git a/__tests__/unit/screens/ModelsScreen/useModelsScreen.test.ts b/__tests__/unit/screens/ModelsScreen/useModelsScreen.test.ts
index 7d8c99be6..407de811a 100644
--- a/__tests__/unit/screens/ModelsScreen/useModelsScreen.test.ts
+++ b/__tests__/unit/screens/ModelsScreen/useModelsScreen.test.ts
@@ -141,16 +141,6 @@ jest.mock('../../../../src/screens/ModelsScreen/useImageModels', () => ({
})),
}));
-// Mock useNotifRationale
-jest.mock('../../../../src/screens/ModelsScreen/useNotifRationale', () => ({
- useNotifRationale: jest.fn(() => ({
- showNotifRationale: false,
- maybeShowNotifRationale: jest.fn((cb) => cb()),
- handleNotifRationaleAllow: jest.fn(),
- handleNotifRationaleDismiss: jest.fn(),
- })),
-}));
-
// Mock useAppStore
jest.mock('../../../../src/stores', () => ({
useAppStore: jest.fn(() => ({
@@ -563,19 +553,10 @@ describe('useModelsScreen', () => {
});
describe('handleDownload callback', () => {
- it('calls maybeShowNotifRationale with download handler', () => {
- const { useNotifRationale } = require('../../../../src/screens/ModelsScreen/useNotifRationale');
- const mockMaybeShowNotifRationale = jest.fn();
+ it('calls text.handleDownload directly with correct args', () => {
+ const { useTextModels } = require('../../../../src/screens/ModelsScreen/useTextModels');
const mockHandleDownload = jest.fn();
- useNotifRationale.mockReturnValue({
- showNotifRationale: false,
- maybeShowNotifRationale: mockMaybeShowNotifRationale,
- handleNotifRationaleAllow: jest.fn(),
- handleNotifRationaleDismiss: jest.fn(),
- });
-
- const { useTextModels } = require('../../../../src/screens/ModelsScreen/useTextModels');
useTextModels.mockReturnValue({
downloadedModels: [],
setIsRefreshing: jest.fn(),
@@ -590,7 +571,6 @@ describe('useModelsScreen', () => {
});
const { result } = renderHook(() => useModelsScreen());
-
const mockModel: any = { id: 'model-id', name: 'Test', author: 'Test', files: [] };
const mockFile: any = { name: 'url', size: 100, quantization: 'Q4', downloadUrl: 'http://test' };
@@ -598,28 +578,15 @@ describe('useModelsScreen', () => {
result.current.handleDownload(mockModel, mockFile);
});
- expect(mockMaybeShowNotifRationale).toHaveBeenCalled();
- // The callback passed to maybeShowNotifRationale should call handleDownload
- const callback = mockMaybeShowNotifRationale.mock.calls[0][0];
- callback();
expect(mockHandleDownload).toHaveBeenCalledWith(mockModel, mockFile);
});
});
describe('handleDownloadImageModel callback', () => {
- it('calls maybeShowNotifRationale with image download handler', () => {
- const { useNotifRationale } = require('../../../../src/screens/ModelsScreen/useNotifRationale');
- const mockMaybeShowNotifRationale = jest.fn();
+ it('calls image.handleDownloadImageModel directly with correct args', () => {
+ const { useImageModels } = require('../../../../src/screens/ModelsScreen/useImageModels');
const mockHandleDownloadImageModel = jest.fn();
- useNotifRationale.mockReturnValue({
- showNotifRationale: false,
- maybeShowNotifRationale: mockMaybeShowNotifRationale,
- handleNotifRationaleAllow: jest.fn(),
- handleNotifRationaleDismiss: jest.fn(),
- });
-
- const { useImageModels } = require('../../../../src/screens/ModelsScreen/useImageModels');
useImageModels.mockReturnValue({
downloadedImageModels: [],
loadDownloadedImageModels: jest.fn().mockResolvedValue(undefined),
@@ -631,24 +598,15 @@ describe('useModelsScreen', () => {
});
const { result } = renderHook(() => useModelsScreen());
-
const mockImageModel: any = {
- id: 'img-model',
- name: 'Test Model',
- description: 'Test',
- downloadUrl: 'http://test',
- size: 100,
- style: 'default',
- backend: 'mnn'
+ id: 'img-model', name: 'Test Model', description: 'Test',
+ downloadUrl: 'http://test', size: 100, style: 'default', backend: 'mnn',
};
act(() => {
result.current.handleDownloadImageModel(mockImageModel);
});
- expect(mockMaybeShowNotifRationale).toHaveBeenCalled();
- const callback = mockMaybeShowNotifRationale.mock.calls[0][0];
- callback();
expect(mockHandleDownloadImageModel).toHaveBeenCalledWith(mockImageModel);
});
});
diff --git a/__tests__/unit/services/backgroundDownloadService.test.ts b/__tests__/unit/services/backgroundDownloadService.test.ts
index cc1afc503..604ebbd28 100644
--- a/__tests__/unit/services/backgroundDownloadService.test.ts
+++ b/__tests__/unit/services/backgroundDownloadService.test.ts
@@ -995,76 +995,6 @@ describe('BackgroundDownloadService', () => {
});
});
- // ========================================================================
- // requestNotificationPermission
- // ========================================================================
- describe('requestNotificationPermission', () => {
- const { PermissionsAndroid } = require('react-native');
-
- beforeEach(() => {
- PermissionsAndroid.request = jest.fn().mockResolvedValue('granted');
- });
-
- it('requests POST_NOTIFICATIONS on Android API 33+', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 33 });
-
- await service.requestNotificationPermission();
-
- expect(PermissionsAndroid.request).toHaveBeenCalledWith(
- PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
- );
- });
-
- it('requests on API 34', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 34 });
-
- await service.requestNotificationPermission();
-
- expect(PermissionsAndroid.request).toHaveBeenCalled();
- });
-
- it('does nothing on iOS', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'ios' });
-
- await service.requestNotificationPermission();
-
- expect(PermissionsAndroid.request).not.toHaveBeenCalled();
- });
-
- it('does nothing on Android API 32', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 32 });
-
- await service.requestNotificationPermission();
-
- expect(PermissionsAndroid.request).not.toHaveBeenCalled();
- });
-
- it('does not throw when permission request rejects', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 33 });
- PermissionsAndroid.request = jest
- .fn()
- .mockRejectedValue(new Error('Permission error'));
-
- await expect(
- service.requestNotificationPermission(),
- ).resolves.toBeUndefined();
- });
-
- it('handles denied permission without throwing', async () => {
- Object.defineProperty(Platform, 'OS', { get: () => 'android' });
- Object.defineProperty(Platform, 'Version', { get: () => 33 });
- PermissionsAndroid.request = jest.fn().mockResolvedValue('denied');
-
- await expect(
- service.requestNotificationPermission(),
- ).resolves.toBeUndefined();
- });
- });
-
// ========================================================================
// downloadFileTo
// ========================================================================
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 422b66a54..4c8c20b2e 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -17,9 +17,6 @@
-
-
-
diff --git a/src/screens/ModelsScreen/index.tsx b/src/screens/ModelsScreen/index.tsx
index 240f5d30f..affd0f3a0 100644
--- a/src/screens/ModelsScreen/index.tsx
+++ b/src/screens/ModelsScreen/index.tsx
@@ -213,16 +213,6 @@ export const ModelsScreen: React.FC = () => {
)}
vm.setAlertState(hideAlert())} />
-
);
};
diff --git a/src/screens/ModelsScreen/useModelsScreen.ts b/src/screens/ModelsScreen/useModelsScreen.ts
index 807065eae..319f9439a 100644
--- a/src/screens/ModelsScreen/useModelsScreen.ts
+++ b/src/screens/ModelsScreen/useModelsScreen.ts
@@ -15,7 +15,6 @@ import { initialFilterState } from './constants';
import { getDirectorySize } from './utils';
import { useTextModels } from './useTextModels';
import { useImageModels } from './useImageModels';
-import { useNotifRationale } from './useNotifRationale';
import { importGgufFiles, getErrorMessage } from './importHelpers';
import { isPickerStuck } from '../../utils/pickerErrorUtils';
@@ -85,15 +84,6 @@ export function useModelsScreen() {
const text = useTextModels(setAlertState);
const image = useImageModels(setAlertState);
- const isFirstDownload =
- text.downloadedModels.length === 0 && image.downloadedImageModels.length === 0;
- const {
- showNotifRationale,
- maybeShowNotifRationale,
- handleNotifRationaleAllow,
- handleNotifRationaleDismiss,
- } = useNotifRationale(isFirstDownload);
-
useEffect(() => {
if (activeTab === 'image' && image.availableHFModels.length === 0 && !image.hfModelsLoading) {
image.loadHFModels();
@@ -194,16 +184,16 @@ export function useModelsScreen() {
const handleDownload = useCallback(
(...args: Parameters) => {
- maybeShowNotifRationale(() => text.handleDownload(...args));
+ text.handleDownload(...args);
},
- [maybeShowNotifRationale, text],
+ [text],
);
const handleDownloadImageModel = useCallback(
(...args: Parameters) => {
- maybeShowNotifRationale(() => image.handleDownloadImageModel(...args));
+ image.handleDownloadImageModel(...args);
},
- [maybeShowNotifRationale, image],
+ [image],
);
return {
@@ -290,9 +280,6 @@ export function useModelsScreen() {
isRecommendedModel: image.isRecommendedModel,
handleDownloadImageModel,
handleCancelImageDownload: image.handleCancelImageDownload,
- showNotifRationale,
- handleNotifRationaleAllow,
- handleNotifRationaleDismiss,
setUserChangedBackendFilter: image.setUserChangedBackendFilter,
};
}
diff --git a/src/screens/ModelsScreen/useNotifRationale.ts b/src/screens/ModelsScreen/useNotifRationale.ts
deleted file mode 100644
index 551d3b891..000000000
--- a/src/screens/ModelsScreen/useNotifRationale.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-import { useState, useRef, useCallback } from 'react';
-import { Platform, PermissionsAndroid } from 'react-native';
-import { backgroundDownloadService } from '../../services';
-import logger from '../../utils/logger';
-
-export function useNotifRationale(isFirstDownload: boolean) {
- const [showNotifRationale, setShowNotifRationale] = useState(false);
- const pendingDownload = useRef<(() => void) | null>(null);
- const hasShownRationale = useRef(false);
-
- const maybeShowNotifRationale = useCallback(async (proceed: () => void) => {
- if (Platform.OS !== 'android' || Platform.Version < 33 || !isFirstDownload || hasShownRationale.current) {
- proceed();
- return;
- }
- const alreadyGranted = await PermissionsAndroid.check(
- PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
- );
- if (alreadyGranted) {
- proceed();
- return;
- }
- hasShownRationale.current = true;
- pendingDownload.current = proceed;
- setShowNotifRationale(true);
- }, [isFirstDownload]);
-
- const handleNotifRationaleAllow = useCallback(() => {
- setShowNotifRationale(false);
- backgroundDownloadService
- .requestNotificationPermission()
- .catch((err) => logger.warn('Failed to request notification permission', err))
- .finally(() => {
- pendingDownload.current?.();
- pendingDownload.current = null;
- });
- }, []);
-
- const handleNotifRationaleDismiss = useCallback(() => {
- setShowNotifRationale(false);
- pendingDownload.current?.();
- pendingDownload.current = null;
- }, []);
-
- return {
- showNotifRationale,
- maybeShowNotifRationale,
- handleNotifRationaleAllow,
- handleNotifRationaleDismiss,
- };
-}
diff --git a/src/services/backgroundDownloadService.ts b/src/services/backgroundDownloadService.ts
index 611c2fd11..5e3e55661 100644
--- a/src/services/backgroundDownloadService.ts
+++ b/src/services/backgroundDownloadService.ts
@@ -1,4 +1,4 @@
-import { NativeModules, NativeEventEmitter, Platform, PermissionsAndroid, Alert } from 'react-native';
+import { NativeModules, NativeEventEmitter, Platform, Alert } from 'react-native';
import { BackgroundDownloadInfo, BackgroundDownloadStatus } from '../types';
import logger from '../utils/logger';
import type {
@@ -175,17 +175,6 @@ class BackgroundDownloadService {
DownloadManagerModule.stopProgressPolling();
}
- async requestNotificationPermission(): Promise {
- if (Platform.OS !== 'android' || Platform.Version < 33) return;
- try {
- await PermissionsAndroid.request(
- PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS,
- );
- } catch {
- // Non-fatal — download still works, just no system notification
- }
- }
-
/** Returns true if battery optimization is ignored, or if unsupported (iOS, old Android). */
async isBatteryOptimizationIgnored(): Promise {
if (Platform.OS !== 'android' || !this.isAvailable()) return true;