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;