├── modules ├── dailywallpaper │ ├── android │ │ ├── src │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── nzran │ │ │ │ └── dailywallpaper │ │ │ │ ├── DailyWallpaperModule.kt │ │ │ │ ├── BackgroundWorker.kt │ │ │ │ └── Utils.kt │ │ └── build.gradle │ ├── expo-module.config.json │ ├── ios │ │ ├── DailyWallpaperView.swift │ │ ├── DailyWallpaper.podspec │ │ └── DailyWallpaperModule.swift │ ├── src │ │ └── DailyWallpaperModule.ts │ └── index.ts ├── download-manager │ ├── android │ │ ├── src │ │ │ └── main │ │ │ │ ├── AndroidManifest.xml │ │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── nzran │ │ │ │ └── downloadmanager │ │ │ │ └── DownloadManagerModule.kt │ │ └── build.gradle │ ├── src │ │ ├── DownloadManager.types.ts │ │ └── DownloadManagerModule.ts │ ├── expo-module.config.json │ ├── ios │ │ ├── DownloadManagerView.swift │ │ ├── DownloadManager.podspec │ │ └── DownloadManagerModule.swift │ └── index.ts └── wallpaper-manager │ ├── android │ ├── src │ │ └── main │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ └── com │ │ │ └── nzran │ │ │ └── wallpapermanager │ │ │ └── WallpaperManagerModule.kt │ └── build.gradle │ ├── src │ ├── WallpaperManager.types.ts │ └── WallpaperManagerModule.ts │ ├── expo-module.config.json │ ├── ios │ ├── WallpaperManagerView.swift │ ├── WallpaperManager.podspec │ └── WallpaperManagerModule.swift │ └── index.ts ├── assets ├── db │ └── mysqlite.db ├── images │ ├── icon.png │ ├── favicon.png │ ├── splash.png │ ├── adaptive-icon.png │ ├── tail.svg │ └── icon.svg ├── fonts │ ├── DMSans │ │ ├── DMSans-Bold.ttf │ │ ├── DMSans-Thin.ttf │ │ ├── DMSans-Black.ttf │ │ ├── DMSans-Light.ttf │ │ ├── DMSans-Medium.ttf │ │ ├── DMSans-Regular.ttf │ │ └── DMSans-SemiBold.ttf │ └── SpaceMono-Regular.ttf └── icons │ └── play_store.svg ├── press ├── screenshots.png └── play_store_badge.png ├── images.d.ts ├── lib ├── icons │ ├── Check.tsx │ ├── ChevronUp.tsx │ ├── ChevronDown.tsx │ └── iconWithClassName.ts ├── utils │ ├── cn.ts │ ├── uuid.ts │ ├── time_since.ts │ ├── sql.ts │ └── process_reddit_post.ts ├── animations │ ├── show_hide_topbar.ts │ └── fading_pulse.ts └── services │ ├── send_error_logs.ts │ ├── wallpaper_type.ts │ ├── get_black_percentage.ts │ ├── search_wallpapers.ts │ └── get_wallpapers.ts ├── nativewind-env.d.ts ├── babel.config.js ├── .prettierrc.js ├── constants ├── sort_options.ts ├── colors.ts └── wallpaper_options.ts ├── tsconfig.json ├── hooks └── useDebounce.ts ├── .gitignore ├── eas.json ├── app ├── +not-found.tsx ├── +html.tsx ├── _layout.tsx ├── (tabs) │ ├── _layout.tsx │ ├── settings.tsx │ ├── index.tsx │ ├── search.tsx │ └── downloaded.tsx └── downloaded_viewer.tsx ├── components ├── ui │ ├── Text.tsx │ ├── LoadingSpinner.tsx │ ├── TopBar.tsx │ ├── Input.tsx │ ├── Select.tsx │ ├── Button.tsx │ └── Switch.tsx ├── SearchTopBar.tsx ├── ParallaxScrollView.tsx ├── PrivacyPolicyDialog.tsx ├── ChangeLog.tsx └── OnlineWallpaperGridItem.tsx ├── appconfig.ts ├── apptheme.js ├── metro.config.js ├── styles └── global.css ├── tailwind.config.js ├── README.md ├── app.json ├── package.json └── store ├── downloaded_wallpapers.ts └── settings.ts /modules/dailywallpaper/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /modules/download-manager/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/db/mysqlite.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/db/mysqlite.db -------------------------------------------------------------------------------- /assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/images/icon.png -------------------------------------------------------------------------------- /press/screenshots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/press/screenshots.png -------------------------------------------------------------------------------- /assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/images/favicon.png -------------------------------------------------------------------------------- /assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/images/splash.png -------------------------------------------------------------------------------- /press/play_store_badge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/press/play_store_badge.png -------------------------------------------------------------------------------- /assets/images/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/images/adaptive-icon.png -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Thin.ttf -------------------------------------------------------------------------------- /assets/fonts/SpaceMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/SpaceMono-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Black.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Light.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Medium.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Medium.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-Regular.ttf -------------------------------------------------------------------------------- /assets/fonts/DMSans/DMSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gauravjot/amoledbackgrounds-app/HEAD/assets/fonts/DMSans/DMSans-SemiBold.ttf -------------------------------------------------------------------------------- /images.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png"; 2 | declare module "*.jpg"; 3 | declare module "*.jpeg"; 4 | declare module "*.svg"; 5 | declare module "*.gif"; 6 | -------------------------------------------------------------------------------- /lib/icons/Check.tsx: -------------------------------------------------------------------------------- 1 | import { Check } from 'lucide-react-native'; 2 | import { iconWithClassName } from './iconWithClassName'; 3 | iconWithClassName(Check); 4 | export { Check }; -------------------------------------------------------------------------------- /nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. -------------------------------------------------------------------------------- /lib/icons/ChevronUp.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronUp } from 'lucide-react-native'; 2 | import { iconWithClassName } from './iconWithClassName'; 3 | iconWithClassName(ChevronUp); 4 | export { ChevronUp }; -------------------------------------------------------------------------------- /modules/download-manager/src/DownloadManager.types.ts: -------------------------------------------------------------------------------- 1 | export type ChangeEventPayload = { 2 | value: string; 3 | }; 4 | 5 | export type DownloadManagerViewProps = { 6 | name: string; 7 | }; 8 | -------------------------------------------------------------------------------- /lib/icons/ChevronDown.tsx: -------------------------------------------------------------------------------- 1 | import { ChevronDown } from 'lucide-react-native'; 2 | import { iconWithClassName } from './iconWithClassName'; 3 | iconWithClassName(ChevronDown); 4 | export { ChevronDown }; -------------------------------------------------------------------------------- /lib/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/src/WallpaperManager.types.ts: -------------------------------------------------------------------------------- 1 | export type ChangeEventPayload = { 2 | success: boolean; 3 | path: string; 4 | }; 5 | 6 | export type WallpaperManagerViewProps = { 7 | name: string; 8 | }; 9 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: [ 5 | ["babel-preset-expo", { jsxImportSource: "nativewind" }], 6 | "nativewind/babel" 7 | ], 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: "avoid", 3 | bracketSameLine: true, 4 | bracketSpacing: false, 5 | singleQuote: false, 6 | trailingComma: "all", 7 | printWidth: 120, 8 | singleAttributePerLine: false, 9 | }; 10 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/expo-module.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "platforms": ["ios", "android"], 3 | "ios": { 4 | "modules": ["WallpaperManagerModule"] 5 | }, 6 | "android": { 7 | "modules": ["com.nzran.wallpapermanager.WallpaperManagerModule"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /constants/sort_options.ts: -------------------------------------------------------------------------------- 1 | // sort options enum 2 | export enum SortOptions { 3 | Hot = "Hot", 4 | New = "New", 5 | "Top 24h" = "Top 24h", 6 | "Top Week" = "Top Week", 7 | "Top Month" = "Top Month", 8 | "Top Year" = "Top Year", 9 | "Top All" = "Top All", 10 | } 11 | -------------------------------------------------------------------------------- /lib/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export default function generateUUID() { 2 | return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { 3 | const r = (Math.random() * 16) | 0; 4 | const v = c === "x" ? r : (r & 0x3) | 0x8; 5 | return v.toString(16); 6 | }); 7 | } 8 | -------------------------------------------------------------------------------- /modules/dailywallpaper/expo-module.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "platforms": ["ios", "tvos", "android", "web"], 3 | "ios": { 4 | "modules": ["DailyWallpaperModule"] 5 | }, 6 | "android": { 7 | "modules": ["com.nzran.dailywallpaper.DailyWallpaperModule"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /modules/download-manager/expo-module.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "platforms": ["ios", "tvos", "android", "web"], 3 | "ios": { 4 | "modules": ["DownloadManagerModule"] 5 | }, 6 | "android": { 7 | "modules": ["com.nzran.downloadmanager.DownloadManagerModule"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /modules/dailywallpaper/ios/DailyWallpaperView.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | // This view will be used as a native component. Make sure to inherit from `ExpoView` 4 | // to apply the proper styling (e.g. border radius and shadows). 5 | class DailyWallpaperView: ExpoView { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /modules/download-manager/ios/DownloadManagerView.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | // This view will be used as a native component. Make sure to inherit from `ExpoView` 4 | // to apply the proper styling (e.g. border radius and shadows). 5 | class DownloadManagerView: ExpoView { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/ios/WallpaperManagerView.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | // This view will be used as a native component. Make sure to inherit from `ExpoView` 4 | // to apply the proper styling (e.g. border radius and shadows). 5 | class WallpaperManagerView: ExpoView { 6 | 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": ["./*"] 7 | } 8 | }, 9 | "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts", "images.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /constants/colors.ts: -------------------------------------------------------------------------------- 1 | export const Colors = { 2 | accent_rgb: "rgb(75, 87, 155)", 3 | accent_foreground_rgb: "rgb(117, 138, 255)", 4 | background_rgb: "rgb(0,0,0)", 5 | foreground_rgb: "rgb(250, 250, 250)", 6 | secondary_rgb: "rgb(39, 39, 42)", 7 | secondary_foreground_rgb: "rgb(217, 217, 217)", 8 | }; 9 | -------------------------------------------------------------------------------- /modules/dailywallpaper/src/DailyWallpaperModule.ts: -------------------------------------------------------------------------------- 1 | import { requireNativeModule } from 'expo-modules-core'; 2 | 3 | // It loads the native module object from the JSI or falls back to 4 | // the bridge module (from NativeModulesProxy) if the remote debugger is on. 5 | export default requireNativeModule('DailyWallpaper'); 6 | -------------------------------------------------------------------------------- /modules/download-manager/src/DownloadManagerModule.ts: -------------------------------------------------------------------------------- 1 | import { requireNativeModule } from 'expo-modules-core'; 2 | 3 | // It loads the native module object from the JSI or falls back to 4 | // the bridge module (from NativeModulesProxy) if the remote debugger is on. 5 | export default requireNativeModule('DownloadManager'); 6 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/src/WallpaperManagerModule.ts: -------------------------------------------------------------------------------- 1 | import { requireNativeModule } from 'expo-modules-core'; 2 | 3 | // It loads the native module object from the JSI or falls back to 4 | // the bridge module (from NativeModulesProxy) if the remote debugger is on. 5 | export default requireNativeModule('WallpaperManager'); 6 | -------------------------------------------------------------------------------- /lib/icons/iconWithClassName.ts: -------------------------------------------------------------------------------- 1 | import type { LucideIcon } from 'lucide-react-native'; 2 | import { cssInterop } from 'nativewind'; 3 | 4 | export function iconWithClassName(icon: LucideIcon) { 5 | cssInterop(icon, { 6 | className: { 7 | target: 'style', 8 | nativeStyleToProp: { 9 | color: true, 10 | opacity: true, 11 | }, 12 | }, 13 | }); 14 | } -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import {useEffect, useState} from "react"; 2 | 3 | export default function useDebounce(value: T, delay?: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay || 500); 8 | 9 | return () => { 10 | clearTimeout(timer); 11 | }; 12 | }, [value, delay]); 13 | 14 | return debouncedValue; 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | /android 13 | local.properties 14 | build/ 15 | .idea 16 | .gradle 17 | .vscode/ 18 | # macOS 19 | .DS_Store 20 | .env*.local 21 | 22 | # @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb 23 | # The following patterns were generated by expo-cli 24 | 25 | expo-env.d.ts 26 | # @end expo-cli 27 | 28 | package-lock.json 29 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 9.1.0" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal" 9 | }, 10 | "preview": { 11 | "distribution": "internal" 12 | }, 13 | "production": { 14 | "env": { 15 | "EXPO_PUBLIC_BUILD_NAME": "PRODUCTION", 16 | "EXPO_PUBLIC_LOGS_SERVER": "https://bigsur.nzran.com" 17 | } 18 | } 19 | }, 20 | "submit": { 21 | "production": {} 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /constants/wallpaper_options.ts: -------------------------------------------------------------------------------- 1 | import {WALLPAPERS_POST_LIMIT} from "@/appconfig"; 2 | 3 | export const WALLPAPERS_URL = "https://www.reddit.com/r/Amoledbackgrounds"; 4 | export const SearchURL = (query: string, page: number, after: string | undefined) => 5 | `https://www.reddit.com/r/Amoledbackgrounds/search.json?q=${query}&count=${page * WALLPAPERS_POST_LIMIT}&after=${ 6 | after || "" 7 | }&restrict_sr=1`; 8 | export const CommentsURL = (postId: string) => `https://www.reddit.com/r/Amoledbackgrounds/comments/${postId}.json`; 9 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Text } from "@/components/ui/Text"; 2 | import { Link, Stack } from "expo-router"; 3 | import { View } from "react-native"; 4 | 5 | export default function NotFoundScreen() { 6 | return ( 7 | <> 8 | 9 | 10 | This screen doesn't exist. 11 | 12 | Go to home screen! 13 | 14 | 15 | > 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /components/ui/Text.tsx: -------------------------------------------------------------------------------- 1 | import {cn} from "@/lib/utils/cn"; 2 | import * as React from "react"; 3 | import {Text as RNText} from "react-native"; 4 | 5 | const Text = React.forwardRef< 6 | React.ElementRef, 7 | React.ComponentPropsWithoutRef 8 | >(({className, ...props}, ref) => { 9 | return ( 10 | 15 | ); 16 | }); 17 | Text.displayName = "Text"; 18 | 19 | export {Text}; 20 | -------------------------------------------------------------------------------- /lib/animations/show_hide_topbar.ts: -------------------------------------------------------------------------------- 1 | import {useAnimatedStyle, withTiming} from "react-native-reanimated"; 2 | 3 | const hideTopBar = (duration: number) => 4 | useAnimatedStyle(() => { 5 | return { 6 | top: withTiming(-200, { 7 | duration: duration, 8 | }), 9 | }; 10 | }); 11 | 12 | const showTopBar = (duration: number) => 13 | useAnimatedStyle(() => { 14 | return { 15 | top: withTiming(0, { 16 | duration: duration, 17 | }), 18 | }; 19 | }); 20 | 21 | export {hideTopBar, showTopBar}; 22 | -------------------------------------------------------------------------------- /appconfig.ts: -------------------------------------------------------------------------------- 1 | export const WALLPAPERS_POST_LIMIT = 50; 2 | export const WALLPAPER_MIN_ALLOWED_WIDTH = 600; 3 | export const WALLPAPER_MIN_ALLOWED_HEIGHT = 1400; 4 | 5 | // Search history limit 6 | export const SEARCH_HISTORY_LIMIT = 10; 7 | 8 | export const PRIVACY_POLICY_URL = "https://droidheat.nzran.com/amoledbackgrounds/privacy_policy_20241117.html"; 9 | export const PRIVACY_POLICY_VERSION = "20241117"; 10 | export const PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.droidheat.amoledbackgrounds"; 11 | export const SEND_ERROR_LOGS_URL = process.env.EXPO_PUBLIC_LOGS_SERVER + "/api/error_logger/bulk_report/"; 12 | -------------------------------------------------------------------------------- /apptheme.js: -------------------------------------------------------------------------------- 1 | const {withAndroidStyles} = require("expo/config-plugins"); 2 | 3 | function withCustomAppTheme(config) { 4 | return withAndroidStyles(config, config => { 5 | let modified = false; 6 | const styles = config.modResults; 7 | styles.resources.style.map(style => { 8 | if (style.$.name === "AppTheme") { 9 | if (!modified) { 10 | style.$.parent = "Theme.AppCompat.NoActionBar"; 11 | modified = true; 12 | } else { 13 | styles.resources.style.splice(styles.resources.style.indexOf(style), 1); 14 | } 15 | } 16 | }); 17 | return config; 18 | }); 19 | } 20 | module.exports = withCustomAppTheme; 21 | -------------------------------------------------------------------------------- /lib/animations/fading_pulse.ts: -------------------------------------------------------------------------------- 1 | import {useAnimatedStyle, withRepeat, withSequence, withTiming} from "react-native-reanimated"; 2 | 3 | // Animations 4 | const fadingPulseAnimation = (duration: number) => 5 | useAnimatedStyle(() => { 6 | return { 7 | opacity: withRepeat( 8 | withSequence( 9 | withTiming(0.5, { 10 | duration: duration / 3, 11 | }), 12 | withTiming(1, { 13 | duration: duration / 3, 14 | }), 15 | withTiming(0.5, { 16 | duration: duration / 3, 17 | }), 18 | ), 19 | -1, 20 | ), 21 | }; 22 | }); 23 | 24 | export {fadingPulseAnimation}; 25 | -------------------------------------------------------------------------------- /modules/dailywallpaper/ios/DailyWallpaper.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'DailyWallpaper' 3 | s.version = '1.0.0' 4 | s.summary = 'A sample project summary' 5 | s.description = 'A sample project description' 6 | s.author = '' 7 | s.homepage = 'https://docs.expo.dev/modules/' 8 | s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 | s.source = { git: '' } 10 | s.static_framework = true 11 | 12 | s.dependency 'ExpoModulesCore' 13 | 14 | # Swift/Objective-C compatibility 15 | s.pod_target_xcconfig = { 16 | 'DEFINES_MODULE' => 'YES', 17 | 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 | } 19 | 20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 | end 22 | -------------------------------------------------------------------------------- /metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const {getDefaultConfig} = require("expo/metro-config"); 3 | const {withNativeWind} = require("nativewind/metro"); 4 | 5 | module.exports = (() => { 6 | const config = getDefaultConfig(__dirname); 7 | 8 | const {transformer, resolver} = config; 9 | 10 | config.transformer = { 11 | ...transformer, 12 | babelTransformerPath: require.resolve("react-native-svg-transformer/expo"), 13 | }; 14 | config.resolver = { 15 | ...resolver, 16 | assetExts: resolver.assetExts.filter(ext => ext !== "svg"), 17 | sourceExts: [...resolver.sourceExts, "svg", "cjs"], 18 | }; 19 | 20 | return withNativeWind(config, {input: "./styles/global.css"}); 21 | })(); 22 | -------------------------------------------------------------------------------- /modules/download-manager/ios/DownloadManager.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'DownloadManager' 3 | s.version = '1.0.0' 4 | s.summary = 'A sample project summary' 5 | s.description = 'A sample project description' 6 | s.author = '' 7 | s.homepage = 'https://docs.expo.dev/modules/' 8 | s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 | s.source = { git: '' } 10 | s.static_framework = true 11 | 12 | s.dependency 'ExpoModulesCore' 13 | 14 | # Swift/Objective-C compatibility 15 | s.pod_target_xcconfig = { 16 | 'DEFINES_MODULE' => 'YES', 17 | 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 | } 19 | 20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 | end 22 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/ios/WallpaperManager.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'WallpaperManager' 3 | s.version = '1.0.0' 4 | s.summary = 'A sample project summary' 5 | s.description = 'A sample project description' 6 | s.author = '' 7 | s.homepage = 'https://docs.expo.dev/modules/' 8 | s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 | s.source = { git: '' } 10 | s.static_framework = true 11 | 12 | s.dependency 'ExpoModulesCore' 13 | 14 | # Swift/Objective-C compatibility 15 | s.pod_target_xcconfig = { 16 | 'DEFINES_MODULE' => 'YES', 17 | 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 | } 19 | 20 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 | end 22 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/index.ts: -------------------------------------------------------------------------------- 1 | // Import the native module. On web, it will be resolved to WallpaperManager.web.ts 2 | // and on native platforms to WallpaperManager.ts 3 | import WallpaperManagerModule from "./src/WallpaperManagerModule"; 4 | import {ChangeEventPayload} from "./src/WallpaperManager.types"; 5 | 6 | export const Module = WallpaperManagerModule; 7 | 8 | export async function setWallpaper(path: string): Promise { 9 | return WallpaperManagerModule.setWallpaper(path); 10 | } 11 | 12 | export async function deleteWallpaper(path: string): Promise { 13 | return WallpaperManagerModule.deleteWallpaper(path); 14 | } 15 | 16 | /* 17 | * Events 18 | */ 19 | 20 | export type ChangeEventType = ChangeEventPayload; 21 | export const ChangeEvent = "onChange"; 22 | -------------------------------------------------------------------------------- /lib/services/send_error_logs.ts: -------------------------------------------------------------------------------- 1 | import {SEND_ERROR_LOGS_URL} from "@/appconfig"; 2 | import axios from "axios"; 3 | import * as SqlUtility from "@/lib/utils/sql"; 4 | 5 | export default async function SendErrorLogs(isSendingEnabled: boolean): Promise { 6 | if (!isSendingEnabled) { 7 | return false; 8 | } 9 | const logs = await SqlUtility.getAllErrorLogs(); 10 | if (logs.length > 0) { 11 | try { 12 | const result = await axios.post(SEND_ERROR_LOGS_URL, logs, { 13 | headers: { 14 | "Content-Type": "application/json", 15 | }, 16 | }); 17 | if (result.status === 204) { 18 | await SqlUtility.deleteAllErrorLogs(); 19 | } 20 | return true; 21 | } catch (_) { 22 | return false; 23 | } 24 | } 25 | return false; 26 | } 27 | -------------------------------------------------------------------------------- /components/SearchTopBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import TopBar from "./ui/TopBar"; 3 | import {Search, View} from "lucide-react-native"; 4 | import useDebounce from "@/hooks/useDebounce"; 5 | import {TextInput} from "react-native"; 6 | import {Text} from "./ui/Text"; 7 | import {Input} from "./ui/Input"; 8 | 9 | export default function SearchTopBar({ 10 | onQueryChanged, 11 | }: { 12 | hide?: boolean; 13 | showLoader?: boolean; 14 | onQueryChanged: (query: string) => void; 15 | }) { 16 | const [query, setQuery] = React.useState(""); 17 | const debouncedQuery = useDebounce(query, 1000); 18 | 19 | React.useEffect(() => { 20 | onQueryChanged(debouncedQuery); 21 | }, [debouncedQuery]); 22 | 23 | return ( 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /components/ui/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from "react"; 2 | import {Loader2} from "lucide-react-native"; 3 | import Animated, {useAnimatedStyle, useSharedValue, withRepeat, withTiming} from "react-native-reanimated"; 4 | 5 | export default function LoadingSpinner({size = 32, color = "white"}: {size?: number; color?: string}) { 6 | const rotation = useSharedValue(0); 7 | 8 | useEffect(() => { 9 | rotation.value = withRepeat(withTiming(1, {duration: 1000}), -1, false); 10 | }, [rotation]); 11 | 12 | const animatedStyle = useAnimatedStyle(() => { 13 | return { 14 | transform: [ 15 | { 16 | rotate: `${rotation.value * 2 * Math.PI}rad`, 17 | }, 18 | ], 19 | }; 20 | }); 21 | 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /lib/services/wallpaper_type.ts: -------------------------------------------------------------------------------- 1 | export type WallpaperPostType = { 2 | id: string; 3 | image: WallpaperImageType; 4 | flair: string; 5 | title: string; 6 | created_utc: Date; 7 | domain?: string; 8 | score: number; 9 | over_18?: boolean; 10 | author: string; 11 | author_flair?: string; 12 | postlink: string; 13 | comments: string; 14 | comments_link: string; 15 | }; 16 | 17 | export type WallpaperImageType = { 18 | url: string; 19 | preview_url?: string; 20 | preview_small_url?: string; 21 | width: number; 22 | height: number; 23 | }; 24 | 25 | export type PaginationType = { 26 | page_number: number; 27 | before?: string; 28 | after?: string; 29 | }; 30 | 31 | export type RedditCommentType = { 32 | id: string; 33 | author: string; 34 | body: string; 35 | score: number; 36 | author_flair: string; 37 | comment_link: string; 38 | parent_id: string; 39 | created_utc: string; 40 | }; 41 | -------------------------------------------------------------------------------- /lib/services/get_black_percentage.ts: -------------------------------------------------------------------------------- 1 | import {CommentsURL} from "@/constants/wallpaper_options"; 2 | import axios from "axios"; 3 | 4 | /** 5 | * Get the black percentage from the comments of a post by AmoledBot 6 | * @param post_id 7 | * @returns 8 | */ 9 | export const getBlackPercentage = async (post_id: string) => { 10 | return await axios.get(CommentsURL(post_id)).then(response => { 11 | const comments = response.data[1].data.children; 12 | let black_percentage = ""; 13 | 14 | for (let i = 0; i < comments.length; i++) { 15 | const author_id = comments[i].data.author_fullname; 16 | if (author_id === "t2_ezs32dqs") { 17 | // Percentage format is 00.00% 18 | const body = comments[i].data.body; 19 | const percentage = body.match(/\d+\.\d+/); 20 | if (percentage) { 21 | black_percentage = percentage[0]; 22 | } 23 | break; 24 | } 25 | } 26 | return black_percentage; 27 | }); 28 | }; 29 | -------------------------------------------------------------------------------- /modules/dailywallpaper/index.ts: -------------------------------------------------------------------------------- 1 | // Import the native module. On web, it will be resolved to DailyWallpaper.web.ts 2 | // and on native platforms to DailyWallpaper.ts 3 | import DailyWallpaperModule from "./src/DailyWallpaperModule"; 4 | 5 | export const Module = DailyWallpaperModule; 6 | 7 | // 8 | export async function registerDailyWallpaperService( 9 | type: "online" | "downloaded", 10 | sort: String | null, 11 | ): Promise { 12 | return await DailyWallpaperModule.registerService(type, sort); 13 | } 14 | 15 | export async function unregisterDailyWallpaperService(): Promise { 16 | return await DailyWallpaperModule.unregisterService(); 17 | } 18 | 19 | export function isDailyWallpaperServiceEnabled() { 20 | return DailyWallpaperModule.isServiceEnabled(); 21 | } 22 | 23 | export function changeDailyWallpaperType(type: "online" | "downloaded") { 24 | return DailyWallpaperModule.changeType(type); 25 | } 26 | 27 | export function changeDailyWallpaperSort(sort: String) { 28 | return DailyWallpaperModule.changeSort(sort); 29 | } 30 | -------------------------------------------------------------------------------- /components/ui/TopBar.tsx: -------------------------------------------------------------------------------- 1 | import {LinearGradient} from "expo-linear-gradient"; 2 | import React from "react"; 3 | import {View} from "react-native"; 4 | import LoadingSpinner from "./LoadingSpinner"; 5 | import TailIcon from "@/assets/images/tail.svg"; 6 | import {Text} from "./Text"; 7 | 8 | export interface TopBarProps { 9 | children?: React.ReactNode; 10 | showLoader?: boolean; 11 | title?: string; 12 | } 13 | 14 | const TopBar = React.forwardRef, TopBarProps>( 15 | ({title, showLoader, children, ...props}, ref) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | {showLoader ? : } 23 | 24 | {title && {title}} 25 | {children ?? <>>} 26 | 27 | 28 | 29 | 30 | ); 31 | }, 32 | ); 33 | 34 | export default TopBar; 35 | -------------------------------------------------------------------------------- /lib/utils/time_since.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Take a date and return a string representing the time since that date 3 | * @param date 4 | */ 5 | export function timeSince(date: Date): string { 6 | const seconds = Math.floor((new Date().getTime() - date.getTime()) / 1000); 7 | 8 | let interval = Math.floor(seconds / 31536000); 9 | if (interval > 1) { 10 | return `${interval} years ago`; 11 | } else if (interval === 1) { 12 | return "an year ago"; 13 | } 14 | interval = Math.floor(seconds / 2592000); 15 | if (interval > 1) { 16 | return `${interval} months ago`; 17 | } else if (interval === 1) { 18 | return "a month ago"; 19 | } 20 | 21 | interval = Math.floor(seconds / 86400); 22 | if (interval > 1) { 23 | return `${interval} days ago`; 24 | } else if (interval === 1) { 25 | return "a day ago"; 26 | } 27 | 28 | interval = Math.floor(seconds / 3600); 29 | if (interval > 1) { 30 | return `${interval} hours ago`; 31 | } else if (interval === 1) { 32 | return "an hour ago"; 33 | } 34 | 7; 35 | 36 | interval = Math.floor(seconds / 60); 37 | if (interval > 1) { 38 | return `${interval} minutes ago`; 39 | } else if (interval === 1) { 40 | return "a minute ago"; 41 | } 42 | 43 | if (seconds < 1) { 44 | return "just now"; 45 | } 46 | return `${Math.floor(seconds)} seconds ago`; 47 | } 48 | -------------------------------------------------------------------------------- /components/ui/Input.tsx: -------------------------------------------------------------------------------- 1 | import {cn} from "@/lib/utils/cn"; 2 | import * as React from "react"; 3 | import {TextInput, View} from "react-native"; 4 | import {Button, ButtonText} from "./Button"; 5 | import {X} from "lucide-react-native"; 6 | 7 | interface InputProps extends React.ComponentPropsWithoutRef { 8 | showClearButton?: boolean; 9 | } 10 | 11 | const Input = React.forwardRef, InputProps>( 12 | ({className, showClearButton, ...props}, ref) => { 13 | return ( 14 | 15 | 25 | {showClearButton && props.value && props.value.length > 0 && ( 26 | { 30 | if (props.onChangeText) { 31 | props.onChangeText(""); 32 | } 33 | }}> 34 | 35 | 36 | )} 37 | 38 | ); 39 | }, 40 | ); 41 | 42 | Input.displayName = "Input"; 43 | 44 | export {Input}; 45 | -------------------------------------------------------------------------------- /modules/download-manager/android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | 3 | group = 'com.nzran.downloadmanager' 4 | version = '0.6.0' 5 | 6 | def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 7 | apply from: expoModulesCorePlugin 8 | applyKotlinExpoModulesCorePlugin() 9 | useCoreDependencies() 10 | useExpoPublishing() 11 | 12 | // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. 13 | // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. 14 | // Most of the time, you may like to manage the Android SDK versions yourself. 15 | def useManagedAndroidSdkVersions = false 16 | if (useManagedAndroidSdkVersions) { 17 | useDefaultAndroidSdkVersions() 18 | } else { 19 | buildscript { 20 | // Simple helper that allows the root project to override versions declared by this library. 21 | ext.safeExtGet = { prop, fallback -> 22 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 23 | } 24 | } 25 | project.android { 26 | compileSdkVersion safeExtGet("compileSdkVersion", 34) 27 | defaultConfig { 28 | minSdkVersion safeExtGet("minSdkVersion", 21) 29 | targetSdkVersion safeExtGet("targetSdkVersion", 34) 30 | } 31 | } 32 | } 33 | 34 | android { 35 | namespace "com.nzran.downloadmanager" 36 | defaultConfig { 37 | versionCode 1 38 | versionName "0.6.0" 39 | } 40 | lintOptions { 41 | abortOnError false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /app/+html.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollViewStyleReset } from 'expo-router/html'; 2 | import { type PropsWithChildren } from 'react'; 3 | 4 | /** 5 | * This file is web-only and used to configure the root HTML for every web page during static rendering. 6 | * The contents of this function only run in Node.js environments and do not have access to the DOM or browser APIs. 7 | */ 8 | export default function Root({ children }: PropsWithChildren) { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | 16 | {/* 17 | Disable body scrolling on web. This makes ScrollView components work closer to how they do on native. 18 | However, body scrolling is often nice to have for mobile web. If you want to enable it, remove this line. 19 | */} 20 | 21 | 22 | {/* Using raw CSS styles as an escape-hatch to ensure the background color never flickers in dark-mode. */} 23 | 24 | {/* Add any additional elements that you want globally available on web... */} 25 | 26 | {children} 27 | 28 | ); 29 | } 30 | 31 | const responsiveBackground = ` 32 | body { 33 | background-color: #fff; 34 | } 35 | @media (prefers-color-scheme: dark) { 36 | body { 37 | background-color: #000; 38 | } 39 | }`; 40 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | 3 | group = 'com.nzran.wallpapermanager' 4 | version = '0.6.0' 5 | 6 | def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 7 | apply from: expoModulesCorePlugin 8 | applyKotlinExpoModulesCorePlugin() 9 | useCoreDependencies() 10 | useExpoPublishing() 11 | 12 | // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. 13 | // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. 14 | // Most of the time, you may like to manage the Android SDK versions yourself. 15 | def useManagedAndroidSdkVersions = false 16 | if (useManagedAndroidSdkVersions) { 17 | useDefaultAndroidSdkVersions() 18 | } else { 19 | buildscript { 20 | // Simple helper that allows the root project to override versions declared by this library. 21 | ext.safeExtGet = { prop, fallback -> 22 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 23 | } 24 | } 25 | project.android { 26 | compileSdkVersion safeExtGet("compileSdkVersion", 34) 27 | defaultConfig { 28 | minSdkVersion safeExtGet("minSdkVersion", 21) 29 | targetSdkVersion safeExtGet("targetSdkVersion", 34) 30 | } 31 | } 32 | } 33 | 34 | android { 35 | namespace "com.nzran.wallpapermanager" 36 | defaultConfig { 37 | versionCode 1 38 | versionName "0.6.0" 39 | } 40 | lintOptions { 41 | abortOnError false 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /styles/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 0%; 8 | --foreground: 0 0% 98%; 9 | --card: 240 10% 3.9%; 10 | --card-foreground: 0 0% 98%; 11 | --popover: 240 10% 3.9%; 12 | --popover-foreground: 0 0% 98%; 13 | --primary: 0 0% 98%; 14 | --primary-foreground: 240 5.9% 10%; 15 | --secondary: 240 3.7% 15.9%; 16 | --secondary-foreground: 0 0% 85%; 17 | --muted: 240 3.7% 15.9%; 18 | --muted-foreground: 240 5% 64.9%; 19 | --accent: 231 35% 45%; 20 | --accent-foreground: 231 100% 80%; 21 | --destructive: 0 72% 60%; 22 | --destructive-foreground: 0 72% 85%; 23 | --border: 240 3.7% 15.9%; 24 | --input: 240 3.7% 15.9%; 25 | --ring: 240 4.9% 83.9%; 26 | } 27 | } 28 | 29 | .font-bold { 30 | font-family: "DMSans_700", sans-serif; 31 | font-weight: 400; 32 | } 33 | .font-black { 34 | font-family: "DMSans_900", sans-serif; 35 | font-weight: 400; 36 | } 37 | .font-medium { 38 | font-family: "DMSans_500", sans-serif; 39 | font-weight: 400; 40 | } 41 | .font-semibold { 42 | font-family: "DMSans_600", sans-serif; 43 | font-weight: 400; 44 | } 45 | .font-normal { 46 | font-family: "DMSans_400", sans-serif; 47 | font-weight: 400; 48 | } 49 | .font-light { 50 | font-family: "DMSans_300", sans-serif; 51 | font-weight: 400; 52 | } 53 | .font-thin { 54 | font-family: "DMSans_200", sans-serif; 55 | font-weight: 400; 56 | } 57 | -------------------------------------------------------------------------------- /modules/dailywallpaper/android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | 3 | group = 'com.nzran.dailywallpaper' 4 | version = '0.6.0' 5 | 6 | def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 7 | apply from: expoModulesCorePlugin 8 | applyKotlinExpoModulesCorePlugin() 9 | useCoreDependencies() 10 | useExpoPublishing() 11 | 12 | // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. 13 | // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. 14 | // Most of the time, you may like to manage the Android SDK versions yourself. 15 | def useManagedAndroidSdkVersions = false 16 | if (useManagedAndroidSdkVersions) { 17 | useDefaultAndroidSdkVersions() 18 | } else { 19 | buildscript { 20 | // Simple helper that allows the root project to override versions declared by this library. 21 | ext.safeExtGet = { prop, fallback -> 22 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 23 | } 24 | } 25 | project.android { 26 | compileSdkVersion safeExtGet("compileSdkVersion", 34) 27 | defaultConfig { 28 | minSdkVersion safeExtGet("minSdkVersion", 21) 29 | targetSdkVersion safeExtGet("targetSdkVersion", 34) 30 | } 31 | } 32 | } 33 | 34 | android { 35 | namespace "com.nzran.dailywallpaper" 36 | defaultConfig { 37 | versionCode 1 38 | versionName "0.6.0" 39 | } 40 | lintOptions { 41 | abortOnError false 42 | } 43 | } 44 | dependencies { 45 | implementation "androidx.preference:preference-ktx:1.2.1" 46 | implementation "androidx.work:work-runtime-ktx:2.7.1" 47 | } -------------------------------------------------------------------------------- /assets/icons/play_store.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /modules/download-manager/index.ts: -------------------------------------------------------------------------------- 1 | // Import the native module. On web, it will be resolved to DownloadManager.web.ts 2 | // and on native platforms to DownloadManager.ts 3 | import DownloadManagerModule from "./src/DownloadManagerModule"; 4 | import {ChangeEventPayload, DownloadManagerViewProps} from "./src/DownloadManager.types"; 5 | 6 | export const Module = DownloadManagerModule; 7 | 8 | /** 9 | * Download a file from the given URL. 10 | */ 11 | export function downloadImage(url: string, filename: string, file_extension: string): number { 12 | return DownloadManagerModule.downloadImage(url, filename, file_extension); 13 | } 14 | 15 | export function getDownloadedFiles( 16 | matchNameStr: string, 17 | ): {name: string; path: string; width: string; height: string}[] { 18 | return DownloadManagerModule.getDownloadedFiles(matchNameStr); 19 | } 20 | 21 | export async function checkFileExists(path: string): Promise { 22 | return DownloadManagerModule.checkFileExists(path); 23 | } 24 | 25 | /* 26 | * Events 27 | */ 28 | 29 | export type DownloadCompleteType = {success: boolean; path: string}; 30 | export const DownloadCompleteEvent = "onDownloadComplete"; 31 | 32 | export type DownloadProgressType = {progress: number; filename: string; downloadId: number}; 33 | export const DownloadProgressEvent = "onDownloadProgress"; 34 | 35 | /* 36 | * Permissions for storage access on Android. 37 | */ 38 | export function hasPermissionForStorage(): boolean { 39 | return DownloadManagerModule.hasPermissionForStorage(); 40 | } 41 | export async function requestStoragePermissionsAsync(): Promise { 42 | return DownloadManagerModule.requestStoragePermissionsAsync(); 43 | } 44 | export function openAppInDeviceSettings(): void { 45 | return DownloadManagerModule.openAppInDeviceSettings(); 46 | } 47 | 48 | // Export types 49 | export {DownloadManagerViewProps, ChangeEventPayload}; 50 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./App.{js,jsx,ts,tsx}", 5 | "./app/**/*.{js,jsx,ts,tsx}", 6 | "./components/**/*.{js,jsx,ts,tsx}", 7 | "./features/**/*.{js,jsx,ts,tsx}", 8 | "./constants/**/*.{js,jsx,ts,tsx}", 9 | "./assets/**/*.{js,jsx,ts,tsx}", 10 | ], 11 | presets: [require("nativewind/preset")], 12 | theme: { 13 | fontSize: { 14 | xs: ".875rem", 15 | sm: "0.925rem", 16 | base: "1.05rem", 17 | lg: "1.125rem", 18 | xl: "1.25rem", 19 | }, 20 | extend: { 21 | fontFamily: { 22 | sans: ["DMSans_400", "sans-serif"], 23 | }, 24 | colors: { 25 | border: "hsl(var(--border))", 26 | input: "hsl(var(--input))", 27 | ring: "hsl(var(--ring))", 28 | background: "hsl(var(--background))", 29 | foreground: "hsl(var(--foreground))", 30 | primary: { 31 | DEFAULT: "hsl(var(--primary))", 32 | foreground: "hsl(var(--primary-foreground))", 33 | }, 34 | secondary: { 35 | DEFAULT: "hsl(var(--secondary))", 36 | foreground: "hsl(var(--secondary-foreground))", 37 | }, 38 | destructive: { 39 | DEFAULT: "hsl(var(--destructive))", 40 | foreground: "hsl(var(--destructive-foreground))", 41 | }, 42 | muted: { 43 | DEFAULT: "hsl(var(--muted))", 44 | foreground: "hsl(var(--muted-foreground))", 45 | }, 46 | accent: { 47 | DEFAULT: "hsl(var(--accent))", 48 | foreground: "hsl(var(--accent-foreground))", 49 | }, 50 | popover: { 51 | DEFAULT: "hsl(var(--popover))", 52 | foreground: "hsl(var(--popover-foreground))", 53 | }, 54 | card: { 55 | DEFAULT: "hsl(var(--card))", 56 | foreground: "hsl(var(--card-foreground))", 57 | }, 58 | }, 59 | }, 60 | }, 61 | plugins: [], 62 | }; 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AmoledBackgrounds 2 | 3 | AmoledBackgrounds provide an easy and quick way for users to get wallpapers from r/AmoledBackgrounds subreddit on popular Reddit platform. 4 | 5 | 6 | 7 | 100k+ Downloads • 900+ Reviews • 4.2 ⭐ Average Rating • Requires Android 11+ 8 | 9 | 10 | 11 |  12 | 13 | ## Features 14 | 15 | ✨ Exclusive Designs - Unique wallpapers crafted for AMOLED displays. 16 | 🔋 Battery-Friendly - Deep blacks save power on AMOLED screens. 17 | 🚀 Easy to Use - Browse, download, and apply wallpapers effortlessly. 18 | 🌍 Updated Regularly - Fresh wallpapers added every day. 19 | ⭐ High-Resolution - 4K and HD wallpapers for a crisp display. 20 | 21 | ## Tech Stack 22 | 23 | - **Framework**: React Native (Expo) 24 | - **State Management**: Zustand 25 | - **Styling**: Tailwind CSS 26 | 27 | ## Contribution 28 | 29 | There are several ways you can contribute to this project: 30 | 31 | 1. Code Contributions: You can help us by writing code, fixing bugs, and implementing new features. Check out the Issues section for tasks that need attention or suggest your own improvements. 32 | 33 | 2. Bug Reports: If you encounter a bug while using [Repository Name], please report it in the Issues section. Be sure to include relevant details that can help us reproduce the issue. 34 | 35 | 3. Feature Requests: Have an idea for a new feature? Share it with us in the Issues section. We encourage discussions around potential enhancements to the project. 36 | 37 | ## License 38 | 39 | CC-BY-NC-4.0 License. You are hereby allowed to use the code in any personal capacity, including modifying and sharing it with others. You are not allowed to use the code for commercial purposes. Please check [LICENSE.md](LICENSE.md) file for complete license. 40 | -------------------------------------------------------------------------------- /modules/dailywallpaper/ios/DailyWallpaperModule.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | public class DailyWallpaperModule: Module { 4 | // Each module class must implement the definition function. The definition consists of components 5 | // that describes the module's functionality and behavior. 6 | // See https://docs.expo.dev/modules/module-api for more details about available components. 7 | public func definition() -> ModuleDefinition { 8 | // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. 9 | // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. 10 | // The module will be accessible from `requireNativeModule('DailyWallpaper')` in JavaScript. 11 | Name("DailyWallpaper") 12 | 13 | // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary. 14 | Constants([ 15 | "PI": Double.pi 16 | ]) 17 | 18 | // Defines event names that the module can send to JavaScript. 19 | Events("onChange") 20 | 21 | // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. 22 | Function("hello") { 23 | return "Hello world! 👋" 24 | } 25 | 26 | // Defines a JavaScript function that always returns a Promise and whose native code 27 | // is by default dispatched on the different thread than the JavaScript runtime runs on. 28 | AsyncFunction("setValueAsync") { (value: String) in 29 | // Send an event to JavaScript. 30 | self.sendEvent("onChange", [ 31 | "value": value 32 | ]) 33 | } 34 | 35 | // Enables the module to be used as a native view. Definition components that are accepted as part of the 36 | // view definition: Prop, Events. 37 | View(DailyWallpaperView.self) { 38 | // Defines a setter for the `name` prop. 39 | Prop("name") { (view: DailyWallpaperView, prop: String) in 40 | print(prop) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/ParallaxScrollView.tsx: -------------------------------------------------------------------------------- 1 | import type { PropsWithChildren, ReactElement } from "react"; 2 | import { StyleSheet, useColorScheme, View } from "react-native"; 3 | import Animated, { 4 | interpolate, 5 | useAnimatedRef, 6 | useAnimatedStyle, 7 | useScrollViewOffset, 8 | } from "react-native-reanimated"; 9 | 10 | const HEADER_HEIGHT = 250; 11 | 12 | type Props = PropsWithChildren<{ 13 | headerImage: ReactElement; 14 | headerBackgroundColor: { dark: string; light: string }; 15 | }>; 16 | 17 | export default function ParallaxScrollView({ 18 | children, 19 | headerImage, 20 | headerBackgroundColor, 21 | }: Props) { 22 | const colorScheme = useColorScheme() ?? "light"; 23 | const scrollRef = useAnimatedRef(); 24 | const scrollOffset = useScrollViewOffset(scrollRef); 25 | 26 | const headerAnimatedStyle = useAnimatedStyle(() => { 27 | return { 28 | transform: [ 29 | { 30 | translateY: interpolate( 31 | scrollOffset.value, 32 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 33 | [-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75] 34 | ), 35 | }, 36 | { 37 | scale: interpolate( 38 | scrollOffset.value, 39 | [-HEADER_HEIGHT, 0, HEADER_HEIGHT], 40 | [2, 1, 1] 41 | ), 42 | }, 43 | ], 44 | }; 45 | }); 46 | 47 | return ( 48 | 49 | 50 | 57 | {headerImage} 58 | 59 | {children} 60 | 61 | 62 | ); 63 | } 64 | 65 | const styles = StyleSheet.create({ 66 | container: { 67 | flex: 1, 68 | }, 69 | header: { 70 | height: 250, 71 | overflow: "hidden", 72 | }, 73 | content: { 74 | flex: 1, 75 | padding: 32, 76 | gap: 16, 77 | overflow: "hidden", 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /modules/download-manager/ios/DownloadManagerModule.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | public class DownloadManagerModule: Module { 4 | // Each module class must implement the definition function. The definition consists of components 5 | // that describes the module's functionality and behavior. 6 | // See https://docs.expo.dev/modules/module-api for more details about available components. 7 | public func definition() -> ModuleDefinition { 8 | // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. 9 | // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. 10 | // The module will be accessible from `requireNativeModule('DownloadManager')` in JavaScript. 11 | Name("DownloadManager") 12 | 13 | // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary. 14 | Constants([ 15 | "PI": Double.pi 16 | ]) 17 | 18 | // Defines event names that the module can send to JavaScript. 19 | Events("onChange") 20 | 21 | // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. 22 | Function("hello") { 23 | return "Hello world! 👋" 24 | } 25 | 26 | // Defines a JavaScript function that always returns a Promise and whose native code 27 | // is by default dispatched on the different thread than the JavaScript runtime runs on. 28 | AsyncFunction("setValueAsync") { (value: String) in 29 | // Send an event to JavaScript. 30 | self.sendEvent("onChange", [ 31 | "value": value 32 | ]) 33 | } 34 | 35 | // Enables the module to be used as a native view. Definition components that are accepted as part of the 36 | // view definition: Prop, Events. 37 | View(DownloadManagerView.self) { 38 | // Defines a setter for the `name` prop. 39 | Prop("name") { (view: DownloadManagerView, prop: String) in 40 | print(prop) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /modules/wallpaper-manager/ios/WallpaperManagerModule.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | public class WallpaperManagerModule: Module { 4 | // Each module class must implement the definition function. The definition consists of components 5 | // that describes the module's functionality and behavior. 6 | // See https://docs.expo.dev/modules/module-api for more details about available components. 7 | public func definition() -> ModuleDefinition { 8 | // Sets the name of the module that JavaScript code will use to refer to the module. Takes a string as an argument. 9 | // Can be inferred from module's class name, but it's recommended to set it explicitly for clarity. 10 | // The module will be accessible from `requireNativeModule('WallpaperManager')` in JavaScript. 11 | Name("WallpaperManager") 12 | 13 | // Sets constant properties on the module. Can take a dictionary or a closure that returns a dictionary. 14 | Constants([ 15 | "PI": Double.pi 16 | ]) 17 | 18 | // Defines event names that the module can send to JavaScript. 19 | Events("onChange") 20 | 21 | // Defines a JavaScript synchronous function that runs the native code on the JavaScript thread. 22 | Function("hello") { 23 | return "Hello world! 👋" 24 | } 25 | 26 | // Defines a JavaScript function that always returns a Promise and whose native code 27 | // is by default dispatched on the different thread than the JavaScript runtime runs on. 28 | AsyncFunction("setValueAsync") { (value: String) in 29 | // Send an event to JavaScript. 30 | self.sendEvent("onChange", [ 31 | "value": value 32 | ]) 33 | } 34 | 35 | // Enables the module to be used as a native view. Definition components that are accepted as part of the 36 | // view definition: Prop, Events. 37 | View(WallpaperManagerView.self) { 38 | // Defines a setter for the `name` prop. 39 | Prop("name") { (view: WallpaperManagerView, prop: String) in 40 | print(prop) 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /components/PrivacyPolicyDialog.tsx: -------------------------------------------------------------------------------- 1 | import {ScrollView, View} from "react-native"; 2 | import {Text} from "./ui/Text"; 3 | import {Button, ButtonText} from "./ui/Button"; 4 | import React from "react"; 5 | import Animated, {FadeInUp, FadeOutDown} from "react-native-reanimated"; 6 | import {PRIVACY_POLICY_URL} from "@/appconfig"; 7 | import * as WebBrowser from "expo-web-browser"; 8 | 9 | export default function PrivacyPolicyDialog({isVisible, onClose}: {isVisible: boolean; onClose: () => void}) { 10 | const TITLE = "Privacy Policy Update"; 11 | 12 | const style = { 13 | body_header: "pb-2 mb-2 text-white border-b border-solid border-zinc-700", 14 | body_text: "text-base leading-7 text-zinc-300", 15 | }; 16 | 17 | return isVisible ? ( 18 | 19 | 23 | {TITLE} 24 | 25 | 26 | By clicking "I Understand" you confirm that you have read and understood the Privacy Policy. 27 | 28 | 29 | { 31 | await WebBrowser.openBrowserAsync(PRIVACY_POLICY_URL); 32 | }} 33 | className="justify-start px-0 text-left" 34 | variant={"link"}> 35 | Read Privacy Policy 36 | 37 | 38 | 39 | { 44 | onClose(); 45 | }}> 46 | I Understand 47 | 48 | 49 | 50 | ) : ( 51 | <>> 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /lib/services/search_wallpapers.ts: -------------------------------------------------------------------------------- 1 | import {SearchURL} from "@/constants/wallpaper_options"; 2 | import axios from "axios"; 3 | import {PaginationType, WallpaperPostType} from "./wallpaper_type"; 4 | import * as SqlUtility from "@/lib/utils/sql"; 5 | import {processRedditPost} from "../utils/process_reddit_post"; 6 | 7 | export const getWallpapersFromSearch = async ( 8 | query: string, 9 | page: number, 10 | after: string | undefined, 11 | deviceIdentifier: string, 12 | ) => { 13 | const url = SearchURL(query, page, after); 14 | 15 | return await axios.get(url).then(response => { 16 | // Process response to get the data we need 17 | const posts: WallpaperPostType[] = []; 18 | 19 | for (let i = 0; i < response.data.data.children.length; i++) { 20 | const post = response.data.data.children[i].data; 21 | 22 | try { 23 | const wallpapers = processRedditPost(post); 24 | if (wallpapers) { 25 | for (let j = 0; j < wallpapers.length; j++) { 26 | const wallpaperPost = wallpapers[j]; 27 | posts.push(wallpaperPost); 28 | } 29 | } 30 | } catch (error) { 31 | // Log error 32 | SqlUtility.insertErrorLog( 33 | { 34 | file: "lib/services/search_wallapers.ts[getWallpapers]", 35 | description: "Error processing post", 36 | error_title: error instanceof Error ? error.name : "", 37 | method: "searchWallpapers", 38 | params: JSON.stringify({ 39 | query: query, 40 | after: after, 41 | page: page, 42 | post: post, 43 | }), 44 | severity: "error", 45 | stacktrace: error instanceof Error ? error.stack || error.message : "", 46 | }, 47 | deviceIdentifier, 48 | ); 49 | } 50 | } 51 | 52 | // Construct pagination object 53 | const pagination: PaginationType = { 54 | page_number: page ?? 1, 55 | before: response.data.data.before, 56 | after: response.data.data.after, 57 | }; 58 | 59 | return {posts: posts, pagination: pagination}; 60 | }); 61 | }; 62 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "AmoledBackgrounds", 4 | "slug": "amoledbackgrounds-app", 5 | "version": "2.1.0", 6 | "android": { 7 | "versionCode": 36, 8 | "adaptiveIcon": { 9 | "foregroundImage": "./assets/images/adaptive-icon.png", 10 | "backgroundColor": "#000000" 11 | }, 12 | "permissions": [ 13 | "android.permission.READ_EXTERNAL_STORAGE", 14 | "android.permission.WRITE_EXTERNAL_STORAGE", 15 | "android.permission.ACCESS_MEDIA_LOCATION", 16 | "android.permission.SET_WALLPAPER", 17 | "android.permission.READ_MEDIA_IMAGES", 18 | "android.permission.ACCESS_NETWORK_STATE", 19 | "android.permission.RECEIVE_BOOT_COMPLETED", 20 | "android.permission.SEND_DOWNLOAD_COMPLETED_INTENTS" 21 | ], 22 | "package": "com.droidheat.amoledbackgrounds", 23 | "userInterfaceStyle": "dark" 24 | }, 25 | "plugins": [ 26 | "expo-router", 27 | [ 28 | "expo-build-properties", 29 | { 30 | "android": { 31 | "compileSdkVersion": 35, 32 | "targetSdkVersion": 35, 33 | "minSdkVersion": 30, 34 | "buildToolsVersion": "35.0.0" 35 | }, 36 | "ios": { 37 | "deploymentTarget": "15.1" 38 | } 39 | } 40 | ], 41 | [ 42 | "expo-asset", 43 | { 44 | "assets": ["assets/images/icon.png"] 45 | } 46 | ], 47 | ["./apptheme.js", {}] 48 | ], 49 | "web": { 50 | "bundler": "metro", 51 | "output": "static", 52 | "favicon": "./assets/images/icon.svg" 53 | }, 54 | "orientation": "portrait", 55 | "icon": "./assets/images/icon.png", 56 | "scheme": "myapp", 57 | "userInterfaceStyle": "dark", 58 | "backgroundColor": "#000000", 59 | "splash": { 60 | "image": "./assets/images/splash.png", 61 | "resizeMode": "contain", 62 | "backgroundColor": "#000000" 63 | }, 64 | "ios": { 65 | "supportsTablet": true 66 | }, 67 | "experiments": { 68 | "typedRoutes": true 69 | }, 70 | "extra": { 71 | "router": { 72 | "origin": false 73 | }, 74 | "eas": { 75 | "projectId": "665ec201-58fb-4ec6-bfc5-76973af8014f" 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import {useFonts} from "expo-font"; 2 | import {Stack} from "expo-router"; 3 | import * as SplashScreen from "expo-splash-screen"; 4 | import {useEffect} from "react"; 5 | import "react-native-reanimated"; 6 | 7 | import "../styles/global.css"; 8 | import {QueryClient, QueryClientProvider} from "@tanstack/react-query"; 9 | import {DarkTheme, ThemeProvider} from "@react-navigation/native"; 10 | import {useSettingsStore} from "@/store/settings"; 11 | import {useDownloadedWallpapersStore} from "@/store/downloaded_wallpapers"; 12 | import {Platform} from "react-native"; 13 | import {SQLiteProvider} from "expo-sqlite"; 14 | 15 | // Prevent the splash screen from auto-hiding before asset loading is complete. 16 | SplashScreen.preventAutoHideAsync(); 17 | 18 | export default function RootLayout() { 19 | // Tanstack query client 20 | const queryClient = new QueryClient(); 21 | 22 | // Load async storage 23 | const settingsStore = useSettingsStore(); 24 | const downloadedStore = useDownloadedWallpapersStore(); 25 | useEffect(() => { 26 | // Initialize stores 27 | settingsStore.initialize(); 28 | if (Platform.OS === "android") { 29 | downloadedStore.initialize(); 30 | } 31 | }, []); 32 | 33 | // Fonts 34 | const [loaded] = useFonts({ 35 | SpaceMono: require("../assets/fonts/SpaceMono-Regular.ttf"), 36 | DMSans_200: require("../assets/fonts/DMSans/DMSans-Thin.ttf"), 37 | DMSans_300: require("../assets/fonts/DMSans/DMSans-Light.ttf"), 38 | DMSans_400: require("../assets/fonts/DMSans/DMSans-Regular.ttf"), 39 | DMSans_500: require("../assets/fonts/DMSans/DMSans-Medium.ttf"), 40 | DMSans_600: require("../assets/fonts/DMSans/DMSans-SemiBold.ttf"), 41 | DMSans_700: require("../assets/fonts/DMSans/DMSans-Bold.ttf"), 42 | DMSans_900: require("../assets/fonts/DMSans/DMSans-Black.ttf"), 43 | }); 44 | 45 | useEffect(() => { 46 | if (loaded) { 47 | SplashScreen.hideAsync(); 48 | } 49 | }, [loaded]); 50 | 51 | if (!loaded) { 52 | return null; 53 | } 54 | 55 | return ( 56 | 57 | 58 | 59 | 63 | 64 | 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amoledbackgrounds-app", 3 | "main": "expo-router/entry", 4 | "version": "2.1.0", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo run:android", 8 | "ios": "expo run:ios", 9 | "web": "expo start --web", 10 | "test": "jest --watchAll", 11 | "lint": "expo lint", 12 | "build-eas-local": "eas build --platform android --profile production --local", 13 | "build-eas-expo": "eas build --profile production --platform android" 14 | }, 15 | "jest": { 16 | "preset": "jest-expo" 17 | }, 18 | "dependencies": { 19 | "@react-native-async-storage/async-storage": "1.23.1", 20 | "@react-navigation/native": "^7.0.0", 21 | "@react-navigation/native-stack": "^7.0.0", 22 | "@rn-primitives/switch": "^1.1.0", 23 | "@tanstack/react-query": "^5.59.15", 24 | "axios": "^1.7.7", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "expo": "^52.0.11", 28 | "expo-asset": "~11.0.1", 29 | "expo-build-properties": "~0.13.1", 30 | "expo-constants": "~17.0.3", 31 | "expo-dev-client": "~5.0.4", 32 | "expo-device": "~7.0.1", 33 | "expo-file-system": "~18.0.3", 34 | "expo-font": "~13.0.1", 35 | "expo-linear-gradient": "~14.0.1", 36 | "expo-linking": "~7.0.3", 37 | "expo-router": "~4.0.9", 38 | "expo-splash-screen": "~0.29.13", 39 | "expo-sqlite": "~15.0.3", 40 | "expo-status-bar": "~2.0.0", 41 | "expo-system-ui": "~4.0.4", 42 | "expo-web-browser": "~14.0.1", 43 | "lucide-react-native": "^0.453.0", 44 | "nativewind": "^4.1.18", 45 | "react": "18.3.1", 46 | "react-dom": "18.3.1", 47 | "react-native": "0.76.3", 48 | "react-native-gesture-handler": "~2.20.2", 49 | "react-native-reanimated": "~3.16.1", 50 | "react-native-reanimated-carousel": "^3.5.1", 51 | "react-native-safe-area-context": "4.12.0", 52 | "react-native-screens": "~4.1.0", 53 | "react-native-svg": "15.8.0", 54 | "react-native-web": "~0.19.10", 55 | "tailwind-merge": "^2.5.4", 56 | "tailwindcss": "^3.4.14", 57 | "url-join": "^5.0.0", 58 | "zustand": "^5.0.0" 59 | }, 60 | "devDependencies": { 61 | "@babel/core": "^7.20.0", 62 | "@react-native-community/cli": "latest", 63 | "@types/jest": "^29.5.12", 64 | "@types/react": "~18.3.12", 65 | "@types/react-test-renderer": "^18.0.7", 66 | "jest": "^29.2.1", 67 | "jest-expo": "~52.0.2", 68 | "react-native-svg-transformer": "^1.5.0", 69 | "react-test-renderer": "18.2.0", 70 | "typescript": "~5.3.3" 71 | }, 72 | "private": true, 73 | "packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610" 74 | } 75 | -------------------------------------------------------------------------------- /store/downloaded_wallpapers.ts: -------------------------------------------------------------------------------- 1 | import {create} from "zustand"; 2 | import * as DownloadManager from "@/modules/download-manager"; 3 | 4 | export interface DownloadedWallpaperStore { 5 | initialize: () => Promise; 6 | files: DownloadedWallpaperPostType[]; 7 | addFile: (file: DownloadedWallpaperPostType) => void; 8 | removeFile: (uri: string) => void; 9 | setFiles: (files: DownloadedWallpaperPostType[]) => void; 10 | exists: (uri: string) => boolean; 11 | getFile: (uri: string) => DownloadedWallpaperPostType | undefined; 12 | } 13 | 14 | export const useDownloadedWallpapersStore = create((set, get) => ({ 15 | initialize: async () => { 16 | const files = await getDownloadedWallpapers(); 17 | set({files: files}); 18 | }, 19 | files: [], 20 | addFile: async (file: DownloadedWallpaperPostType) => { 21 | set(state => { 22 | return {files: [...state.files, file]}; 23 | }); 24 | }, 25 | removeFile: (uri: string) => { 26 | set(state => { 27 | return {files: state.files.filter(file => !file.path.includes(uri))}; 28 | }); 29 | }, 30 | setFiles: (files: DownloadedWallpaperPostType[]) => { 31 | set({files: files}); 32 | }, 33 | exists: (uri: string) => get().files.some(file => file.path.includes(uri)), 34 | getFile: (uri: string) => get().files.find(file => file.path.includes(uri)), 35 | })); 36 | 37 | const getDownloadedWallpapers = async () => { 38 | const files = DownloadManager.getDownloadedFiles("_amoled_droidheat"); 39 | const list: DownloadedWallpaperPostType[] = []; 40 | 41 | for (let i = 0; i < files.length; i++) { 42 | const file = files[i]; 43 | // Two possible formats 44 | // 1. name_t3_[id]_amoled_droidheat.jpg 45 | // 2. name_-_[id]_amoled_droidheat.jpg 46 | let name = file.name.replace("_amoled_droidheat", ""); 47 | // remove extension 48 | name = name.split(".").slice(0, -1).join("."); 49 | let id = ""; 50 | if (name.includes("_t3_")) { 51 | const split = name.split("_t3_"); 52 | name = split[0]; 53 | id = split[1]; 54 | } else { 55 | const split = name.split("_-_"); 56 | name = split[0]; 57 | id = split[split.length - 1]; 58 | } 59 | 60 | list.push({ 61 | id: id, 62 | title: name.replace(/_/g, " ").replace(/_/g, " ").trim(), 63 | path: file.path, 64 | width: file.width.length > 0 ? parseInt(file.width) : null, 65 | height: file.height.length > 0 ? parseInt(file.height) : null, 66 | }); 67 | } 68 | return list; 69 | }; 70 | 71 | export type DownloadedWallpaperPostType = { 72 | id: string; 73 | title: string; 74 | path: string; 75 | width: number | null; 76 | height: number | null; 77 | }; 78 | -------------------------------------------------------------------------------- /components/ChangeLog.tsx: -------------------------------------------------------------------------------- 1 | import {Dimensions, ScrollView, View} from "react-native"; 2 | import {Text} from "./ui/Text"; 3 | import {Button, ButtonText} from "./ui/Button"; 4 | import React from "react"; 5 | import Animated, {FadeInUp, FadeOutDown} from "react-native-reanimated"; 6 | import {Send, Sparkles} from "lucide-react-native"; 7 | 8 | export default function ChangeLogDialog({isVisible, onClose}: {isVisible: boolean; onClose: () => void}) { 9 | const CHANGELOG_TITLE = "🎉 V2.1.0 is here!"; 10 | const width = Dimensions.get("window").width; 11 | const height = Dimensions.get("window").height; 12 | 13 | const style = { 14 | body_header: "pb-2 mb-2 mt-4 border-b border-solid border-zinc-700 flex flex-row items-center gap-3", 15 | body_header_text: "leading-7 text-white", 16 | body_text: "text-base leading-7 text-zinc-300", 17 | }; 18 | 19 | return isVisible ? ( 20 | 21 | 26 | {CHANGELOG_TITLE} 27 | 28 | 29 | Thank You for using AmoledBackgrounds. This version brings following changes to the app — 30 | 31 | 32 | 33 | NEW 34 | 35 | {`• Black Pixel Percentage. Thanks to AmoledBot on subreddit.`} 36 | {`• Support for gallery posts.`} 37 | 38 | 39 | Contact Developer 40 | 41 | {`Email: droidheat@gmail.com`} 42 | {`Reddit: u/droidheat\n`} 43 | 44 | { 49 | onClose(); 50 | }}> 51 | Let's GO 52 | 53 | 54 | 55 | ) : ( 56 | <>> 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /components/ui/Select.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import {Pressable, View} from "react-native"; 3 | import Animated, { 4 | FadeInUp, 5 | FadeOutDown, 6 | FadeOutUp, 7 | useAnimatedStyle, 8 | withDelay, 9 | withTiming, 10 | } from "react-native-reanimated"; 11 | import {Button, ButtonText} from "./Button"; 12 | import {ChevronDown} from "lucide-react-native"; 13 | 14 | /** 15 | * Renders a select component 16 | * @param defaultValue string - Default value of dropdown 17 | * @param {string[]} options - Options in the dropdown 18 | * @param {(value: string) => void} onChange - The change handler 19 | * @param {"left" | "right"} align - The alignment of dropdown. Default is "left". 20 | * @param {number} width - The width of dropdown. Default is 110. 21 | * @returns 22 | */ 23 | export default function Select({ 24 | defaultValue, 25 | options, 26 | onChange, 27 | align = "left", 28 | width = 110, 29 | }: { 30 | defaultValue: string; 31 | options: string[]; 32 | onChange: (value: string) => void; 33 | align?: "left" | "right"; 34 | width?: number; 35 | }) { 36 | const [isOpen, setIsOpen] = React.useState(false); 37 | const [selected, setSelected] = React.useState(defaultValue); 38 | 39 | return ( 40 | 41 | setIsOpen(v => !v)}> 46 | {selected} 47 | 48 | 49 | {isOpen && ( 50 | 54 | 55 | {options.map(o => ( 56 | { 62 | setIsOpen(false); 63 | onChange(o); 64 | setSelected(o); 65 | }}> 66 | 67 | {o} 68 | 69 | 70 | ))} 71 | 72 | 73 | )} 74 | {/* catch outside presses */} 75 | setIsOpen(false)} 77 | className={`${!isOpen && "hidden"} absolute -top-96 -right-96 z-40 w-[200vw] h-[200vh]`} 78 | /> 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /components/ui/Button.tsx: -------------------------------------------------------------------------------- 1 | import {cva, type VariantProps} from "class-variance-authority"; 2 | import * as React from "react"; 3 | import {Pressable} from "react-native"; 4 | import {cn} from "@/lib/utils/cn"; 5 | import {Text} from "./Text"; 6 | 7 | const buttonVariants = cva("group flex items-center gap-2 justify-center rounded-md", { 8 | variants: { 9 | variant: { 10 | destructive: "bg-destructive active:opacity-90", 11 | outline: "border border-input bg-background active:bg-accent", 12 | secondary: "bg-secondary active:opacity-80", 13 | ghost: "active:bg-white/10", 14 | link: "active:underline", 15 | accent: "bg-accent active:bg-accent/90", 16 | emerald: "bg-emerald-700 active:bg-emerald-800", 17 | }, 18 | size: { 19 | default: "h-14 px-5", 20 | xs: "h-6 px-2", 21 | sm: "h-8 px-3", 22 | md: "h-10 px-5", 23 | lg: "px-8 h-16", 24 | icon: "h-10 w-10", 25 | }, 26 | flex: { 27 | row: "flex-row", 28 | column: "flex-col", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "accent", 33 | size: "default", 34 | flex: "row", 35 | }, 36 | }); 37 | 38 | const buttonTextVariants = cva("font-medium", { 39 | variants: { 40 | variant: { 41 | default: "text-white", 42 | destructive: "text-destructive-foreground", 43 | outline: "group-active:text-accent-foreground", 44 | secondary: "text-secondary-foreground group-active:text-secondary-foreground", 45 | ghost: "group-active:text-accent-foreground", 46 | link: "text-primary group-active:underline", 47 | accent: "text-accent-foreground", 48 | }, 49 | size: { 50 | default: "text-base", 51 | sm: "text-sm", 52 | md: "text-sm", 53 | lg: "text-lg", 54 | icon: "", 55 | }, 56 | }, 57 | defaultVariants: { 58 | variant: "default", 59 | size: "default", 60 | }, 61 | }); 62 | 63 | type ButtonProps = React.ComponentPropsWithoutRef & VariantProps; 64 | 65 | const Button = React.forwardRef, ButtonProps>( 66 | ({className, variant, size, flex, ...props}, ref) => { 67 | return ( 68 | 74 | ); 75 | }, 76 | ); 77 | Button.displayName = "Button"; 78 | 79 | const ButtonText = React.forwardRef< 80 | React.ElementRef, 81 | React.ComponentPropsWithoutRef & VariantProps 82 | >(({className, variant, size, ...props}, ref) => { 83 | return ( 84 | 89 | ); 90 | }); 91 | ButtonText.displayName = "ButtonText"; 92 | 93 | export {Button, buttonVariants, ButtonText, buttonTextVariants}; 94 | export type {ButtonProps}; 95 | -------------------------------------------------------------------------------- /components/ui/Switch.tsx: -------------------------------------------------------------------------------- 1 | import {Colors} from "@/constants/colors"; 2 | import {cn} from "@/lib/utils/cn"; 3 | import * as SwitchPrimitives from "@rn-primitives/switch"; 4 | import * as React from "react"; 5 | import {Platform} from "react-native"; 6 | import Animated, {interpolateColor, useAnimatedStyle, useDerivedValue, withTiming} from "react-native-reanimated"; 7 | 8 | const SwitchWeb = React.forwardRef( 9 | ({className, ...props}, ref) => ( 10 | 19 | 25 | 26 | ), 27 | ); 28 | 29 | SwitchWeb.displayName = "SwitchWeb"; 30 | 31 | const SwitchNative = React.forwardRef( 32 | ({className, ...props}, ref) => { 33 | const translateX = useDerivedValue(() => (props.checked ? 18 : 0)); 34 | const animatedRootStyle = useAnimatedStyle(() => { 35 | return { 36 | backgroundColor: interpolateColor(translateX.value, [0, 18], [Colors.secondary_rgb, Colors.accent_rgb]), 37 | }; 38 | }); 39 | const animatedThumbStyle = useAnimatedStyle(() => ({ 40 | transform: [{translateX: withTiming(translateX.value, {duration: 200})}], 41 | })); 42 | return ( 43 | 46 | 54 | 55 | 60 | 61 | 62 | 63 | ); 64 | }, 65 | ); 66 | SwitchNative.displayName = "SwitchNative"; 67 | 68 | const Switch = Platform.select({ 69 | web: SwitchWeb, 70 | default: SwitchNative, 71 | }); 72 | 73 | export {Switch}; 74 | -------------------------------------------------------------------------------- /modules/dailywallpaper/android/src/main/java/com/nzran/dailywallpaper/DailyWallpaperModule.kt: -------------------------------------------------------------------------------- 1 | package com.nzran.dailywallpaper 2 | 3 | import expo.modules.kotlin.modules.Module 4 | import expo.modules.kotlin.modules.ModuleDefinition 5 | import androidx.preference.PreferenceManager 6 | import android.util.Log 7 | import androidx.work.ExistingPeriodicWorkPolicy 8 | import androidx.work.PeriodicWorkRequestBuilder 9 | import androidx.work.WorkManager 10 | import java.util.Date 11 | 12 | class DailyWallpaperModule : Module() { 13 | 14 | private val context 15 | get() = requireNotNull(appContext.reactContext) 16 | 17 | override fun definition() = ModuleDefinition { 18 | Name("DailyWallpaper") 19 | 20 | // Register for the daily wallpaper service 21 | AsyncFunction("registerService") { type: String, sort: String -> 22 | val sharedPref = PreferenceManager.getDefaultSharedPreferences(context) 23 | // Check if service is already enabled 24 | if (sharedPref.getBoolean("enabled", false)) { 25 | throw IllegalStateException("Service already enabled") 26 | } 27 | val sharedPrefEditor = sharedPref.edit() 28 | // Save in shared preferences 29 | sharedPrefEditor.putString("type", type) 30 | sharedPrefEditor.putString("sort", sort) 31 | sharedPrefEditor.putBoolean("enabled", true) 32 | sharedPrefEditor.putLong("timestamp", Date().time) 33 | sharedPrefEditor.apply() 34 | 35 | // Schedule the worker 36 | val workManager = WorkManager.getInstance(context) 37 | val workRequest = PeriodicWorkRequestBuilder(1, java.util.concurrent.TimeUnit.DAYS) 38 | .addTag("dailyWallpaper") 39 | .build() 40 | workManager.enqueueUniquePeriodicWork("dailyWallpaper", ExistingPeriodicWorkPolicy.REPLACE, workRequest) 41 | 42 | return@AsyncFunction workRequest.id.toString() 43 | } 44 | 45 | // Unregister for the daily wallpaper service 46 | AsyncFunction("unregisterService") { 47 | val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) 48 | sharedPreferences.edit().putBoolean("enabled", false).apply() 49 | // Cancel the worker 50 | val workManager = WorkManager.getInstance(context) 51 | workManager.cancelAllWorkByTag("dailyWallpaper") 52 | workManager.cancelUniqueWork("dailyWallpaper") 53 | Log.d(TAG, "Worker cancelled") 54 | return@AsyncFunction true 55 | } 56 | 57 | // Check if the daily wallpaper service is enabled 58 | Function("isServiceEnabled") { 59 | val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) 60 | return@Function sharedPreferences.getBoolean("enabled", false) 61 | } 62 | 63 | Function("changeType") { type: String -> 64 | val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) 65 | sharedPreferences.edit().putString("type", type).apply() 66 | } 67 | 68 | Function("changeSort") { sort: String -> 69 | val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) 70 | sharedPreferences.edit().putString("sort", sort).apply() 71 | } 72 | } 73 | 74 | companion object { 75 | private const val TAG = "DailyWallpaperModule" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /lib/services/get_wallpapers.ts: -------------------------------------------------------------------------------- 1 | import {SortOptions} from "@/constants/sort_options"; 2 | import {WALLPAPERS_URL} from "../../constants/wallpaper_options"; 3 | import urlJoin from "url-join"; 4 | import axios from "axios"; 5 | import {PaginationType, WallpaperPostType} from "./wallpaper_type"; 6 | import {WALLPAPERS_POST_LIMIT} from "@/appconfig"; 7 | import * as SqlUtility from "@/lib/utils/sql"; 8 | import {processRedditPost} from "../utils/process_reddit_post"; 9 | 10 | export const getWallpapers = async ( 11 | sort: SortOptions, 12 | after: string | undefined, 13 | page_number: number, 14 | deviceIdentifier: string, 15 | ) => { 16 | const url = urlJoin( 17 | WALLPAPERS_URL, 18 | getURIFromSort(sort), 19 | `?limit=${WALLPAPERS_POST_LIMIT}`, 20 | after ? `&after=${after}` : "", 21 | `&count=${WALLPAPERS_POST_LIMIT * (page_number ?? 1)}`, 22 | ); 23 | 24 | return await axios.get(url).then(response => { 25 | // Process response to get the data we need 26 | const posts: WallpaperPostType[] = []; 27 | 28 | for (let i = 0; i < response.data.data.children.length; i++) { 29 | const post = response.data.data.children[i].data; 30 | 31 | try { 32 | const wallpapers = processRedditPost(post); 33 | if (wallpapers) { 34 | for (let j = 0; j < wallpapers.length; j++) { 35 | const wallpaperPost = wallpapers[j]; 36 | posts.push(wallpaperPost); 37 | } 38 | } 39 | } catch (error) { 40 | // Log error 41 | SqlUtility.insertErrorLog( 42 | { 43 | file: "lib/services/get_wallapers.ts[getWallpapers]", 44 | description: "Error processing post", 45 | error_title: error instanceof Error ? error.name : "", 46 | method: "getWallpapers", 47 | params: JSON.stringify({ 48 | sort: sort, 49 | after: after, 50 | page_number: page_number, 51 | post: post, 52 | }), 53 | severity: "error", 54 | stacktrace: error instanceof Error ? error.stack || error.message : "", 55 | }, 56 | deviceIdentifier, 57 | ); 58 | } 59 | } 60 | // Construct pagination object 61 | const pagination: PaginationType = { 62 | page_number: page_number ?? 1, 63 | before: response.data.data.before, 64 | after: response.data.data.after, 65 | }; 66 | 67 | return { 68 | posts: posts, 69 | pagination: pagination, 70 | }; 71 | }); 72 | }; 73 | 74 | /** 75 | * Get URL substring based on provided sort 76 | * @param sort 77 | * @returns 78 | */ 79 | export function getURIFromSort(sort: SortOptions) { 80 | switch (sort) { 81 | case SortOptions.Hot: 82 | return "hot.json"; 83 | case SortOptions.New: 84 | return "new.json"; 85 | case SortOptions["Top 24h"]: 86 | return "top.json?t=day"; 87 | case SortOptions["Top Week"]: 88 | return "top.json?t=week"; 89 | case SortOptions["Top Month"]: 90 | return "top.json?t=month"; 91 | case SortOptions["Top Year"]: 92 | return "top.json?t=year"; 93 | case SortOptions["Top All"]: 94 | return "top.json?t=all"; 95 | default: 96 | return "hot.json"; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /components/OnlineWallpaperGridItem.tsx: -------------------------------------------------------------------------------- 1 | import {View, Image, Pressable} from "react-native"; 2 | import {Text} from "@/components/ui/Text"; 3 | import {WallpaperPostType} from "@/lib/services/wallpaper_type"; 4 | import {ArrowUp} from "lucide-react-native"; 5 | import {timeSince} from "@/lib/utils/time_since"; 6 | import Animated from "react-native-reanimated"; 7 | import {fadingPulseAnimation} from "@/lib/animations/fading_pulse"; 8 | import {useRouter} from "expo-router"; 9 | import {useSettingsStore} from "@/store/settings"; 10 | 11 | export default function OnlineWallpaperGridItem(wallpaper: WallpaperPostType) { 12 | const router = useRouter(); 13 | const store = useSettingsStore(); 14 | const thumbnail: string = 15 | (store.isLowerThumbnailQualityEnabled ? wallpaper.image.preview_small_url : null) ?? 16 | wallpaper.image.preview_url ?? 17 | wallpaper.image.url; 18 | 19 | function openDownloadScreen() { 20 | router.push({pathname: "/download", params: {wallpaper: JSON.stringify(wallpaper)}}); 21 | } 22 | 23 | return ( 24 | 25 | 28 | 29 | 30 | 33 | 37 | {wallpaper.flair && ( 38 | 39 | 40 | {wallpaper.flair} 41 | 42 | 43 | )} 44 | 45 | 46 | 47 | {wallpaper.score} 48 | 49 | 50 | 51 | 52 | {wallpaper.image.width} x {wallpaper.image.height} 53 | 54 | 55 | 56 | 57 | 58 | {wallpaper.title} 59 | 60 | 61 | 62 | {timeSince(wallpaper.created_utc)} • {wallpaper.author} 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /lib/utils/sql.ts: -------------------------------------------------------------------------------- 1 | import * as SQLite from "expo-sqlite"; 2 | import * as Device from "expo-device"; 3 | import Constants from "expo-constants"; 4 | 5 | const DB_NAME = "mysqlite.db"; 6 | const APP_NAME = "AmoledBackgrounds:" + (Constants.expoConfig?.version ?? "Unknown"); 7 | const DEVICE_NAME = Device.designName || "Unknown"; 8 | const DEVICE_PLATFORM = Device.platformApiLevel || 0; 9 | const DEVICE_MODEL = Device.manufacturer + " " + Device.modelName; 10 | 11 | export const setupDatabase = async () => { 12 | const db = await SQLite.openDatabaseAsync(DB_NAME); 13 | // error logger 14 | await db.execAsync(` 15 | PRAGMA journal_mode = WAL; 16 | CREATE TABLE IF NOT EXISTS errorlogs ( 17 | id INTEGER PRIMARY KEY NOT NULL, 18 | app_name TEXT NOT NULL CHECK (length(app_name) <= 200), 19 | error_title TEXT NOT NULL CHECK (length(error_title) <= 200), 20 | description TEXT NOT NULL, 21 | file TEXT NOT NULL CHECK (length(file) <= 255), 22 | method TEXT NOT NULL CHECK (length(method) <= 200), 23 | stacktrace TEXT, 24 | params TEXT, 25 | severity TEXT NOT NULL CHECK (length(severity) <= 200), 26 | timestamp_occured TIMESTAMP NOT NULL, 27 | identifier TEXT NOT NULL, 28 | device_model TEXT NOT NULL CHECK (length(device_model) <= 200), 29 | device_platform INTEGER NOT NULL, 30 | device_name TEXT NOT NULL CHECK (length(device_name) <= 200) 31 | ); 32 | `); 33 | }; 34 | 35 | export type ErrorLog = { 36 | app_name: string; 37 | error_title: string; 38 | description: string; 39 | file: string; 40 | method: string; 41 | stacktrace: string; 42 | params: string; 43 | severity: string; 44 | timestamp_occured: string; 45 | identifier: string; 46 | device_model: string; 47 | device_platform: number; 48 | device_name: string; 49 | }; 50 | 51 | export type RequiredErrorLog = Omit< 52 | ErrorLog, 53 | "app_name" | "timestamp_occured" | "identifier" | "device_model" | "device_platform" | "device_name" 54 | >; 55 | 56 | export const insertErrorLog = async (errorLog: RequiredErrorLog, deviceIdentifier: string) => { 57 | const db = await SQLite.openDatabaseAsync(DB_NAME); 58 | const query = ` 59 | INSERT INTO errorlogs ( 60 | app_name, 61 | error_title, 62 | description, 63 | file, 64 | method, 65 | stacktrace, 66 | params, 67 | severity, 68 | timestamp_occured, 69 | identifier, 70 | device_model, 71 | device_platform, 72 | device_name 73 | ) VALUES ( 74 | "${APP_NAME}", 75 | "${esip(errorLog.error_title)}", 76 | "${esip(errorLog.description)}", 77 | "${esip(errorLog.file)}", 78 | "${esip(errorLog.method)}", 79 | "${esip(errorLog.stacktrace)}", 80 | "${esip(errorLog.params)}", 81 | "${errorLog.severity}", 82 | "${new Date().toISOString()}", 83 | "${deviceIdentifier}", 84 | "${DEVICE_MODEL}", 85 | ${DEVICE_PLATFORM}, 86 | "${DEVICE_NAME}" 87 | ); 88 | `; 89 | await db.execAsync(query); 90 | }; 91 | 92 | export const getAllErrorLogs = async (): Promise => { 93 | const db = await SQLite.openDatabaseAsync(DB_NAME); 94 | const allRows = await db.getAllAsync("SELECT * FROM errorlogs"); 95 | return (allRows as (ErrorLog & {id: number})[]).map(row => { 96 | const {id, ...rest} = row; 97 | return rest; 98 | }); 99 | }; 100 | 101 | export const deleteAllErrorLogs = async () => { 102 | const db = await SQLite.openDatabaseAsync(DB_NAME); 103 | await db.execAsync("DELETE FROM errorlogs"); 104 | }; 105 | 106 | const esip = (val: string | null) => { 107 | return val ? val.toString().replace(/"/g, '""') : val; 108 | }; 109 | -------------------------------------------------------------------------------- /modules/dailywallpaper/android/src/main/java/com/nzran/dailywallpaper/BackgroundWorker.kt: -------------------------------------------------------------------------------- 1 | package com.nzran.dailywallpaper 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.DownloadManager 5 | import android.content.BroadcastReceiver 6 | import android.content.Context 7 | import android.content.Intent 8 | import android.content.IntentFilter 9 | import android.os.Build 10 | import android.util.Log 11 | import androidx.preference.PreferenceManager 12 | import androidx.work.CoroutineWorker 13 | import androidx.work.WorkerParameters 14 | import kotlinx.coroutines.Dispatchers 15 | import kotlinx.coroutines.withContext 16 | 17 | // Worker class is used to perform background task 18 | class BackgroundWorker(context: Context, workerParams: WorkerParameters) : 19 | CoroutineWorker(context, workerParams) { 20 | 21 | private var filepath: String? = null 22 | private var downloadId: Long = -1 23 | 24 | @SuppressLint("UnspecifiedRegisterReceiverFlag") 25 | override suspend fun doWork(): Result { 26 | return withContext(Dispatchers.IO) { 27 | Log.d(TAG, "Scheduled worker started") 28 | val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) 29 | if (!sharedPrefs.getBoolean("enabled", false)) { 30 | Log.d(TAG, "Daily wallpaper is not enabled. Worker is exiting.") 31 | return@withContext Result.success() 32 | } 33 | 34 | // get the wallpaper 35 | Log.d(TAG, "Getting wallpaper: " + sharedPrefs.getString("type", "online") + ", " + sharedPrefs.getString("sort", "new.json")) 36 | val wallpaper = Utils.getWallpaper( 37 | applicationContext, sharedPrefs.getString("type", "online") ?: "online", 38 | sharedPrefs.getString("sort", "new.json") ?: "new.json" 39 | ) 40 | 41 | // if wallpaper type is HashMap, then it has a download id and we need to wait for the download to complete 42 | if (wallpaper is HashMap<*, *>) { 43 | // wait for the download to complete 44 | Log.d(TAG, "Waiting for download to complete") 45 | downloadId = wallpaper["downloadId"] as Long 46 | filepath = wallpaper["path"] as String 47 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 48 | applicationContext.registerReceiver( 49 | downloadReceiver, 50 | IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), 51 | Context.RECEIVER_EXPORTED 52 | ) 53 | } else { 54 | applicationContext.registerReceiver( 55 | downloadReceiver, 56 | IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) 57 | ) 58 | } 59 | } else if (wallpaper is String && wallpaper.isNotEmpty()) { 60 | // if wallpaper is a String, then it is path to the wallpaper 61 | // set wallpaper 62 | Log.d(TAG, "Setting wallpaper $wallpaper") 63 | Utils.setWallpaper(applicationContext, wallpaper) 64 | } else { 65 | Log.d(TAG, "Failed to get wallpaper") 66 | Log.d(TAG, wallpaper.toString()) 67 | } 68 | return@withContext Result.success() 69 | } 70 | } 71 | 72 | // Broadcast receiver to listen for download completion 73 | private val downloadReceiver = object : BroadcastReceiver() { 74 | override fun onReceive(context: Context, intent: Intent) { 75 | val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) 76 | if (id == downloadId) { 77 | // set wallpaper 78 | Log.d(TAG, "Setting wallpaper") 79 | Utils.setWallpaper(context, filepath!!) 80 | // unregister receiver 81 | context.unregisterReceiver(this) 82 | } 83 | } 84 | } 85 | 86 | companion object { 87 | private const val TAG = "DailyWallpaperBackgroundWorker" 88 | } 89 | } -------------------------------------------------------------------------------- /modules/wallpaper-manager/android/src/main/java/com/nzran/wallpapermanager/WallpaperManagerModule.kt: -------------------------------------------------------------------------------- 1 | package com.nzran.wallpapermanager 2 | 3 | import android.annotation.SuppressLint 4 | import expo.modules.kotlin.modules.Module 5 | import expo.modules.kotlin.modules.ModuleDefinition 6 | import android.content.Context 7 | import android.app.WallpaperManager 8 | import android.content.ContentUris 9 | import android.graphics.Bitmap 10 | import android.graphics.BitmapFactory 11 | import android.graphics.ImageDecoder 12 | import java.io.IOException 13 | import android.provider.MediaStore 14 | import android.net.Uri 15 | import android.media.MediaScannerConnection 16 | import android.util.Log 17 | import androidx.core.os.bundleOf 18 | 19 | class WallpaperManagerModule : Module() { 20 | 21 | private val context 22 | get() = requireNotNull(appContext.reactContext) 23 | 24 | override fun definition() = ModuleDefinition { 25 | Name("WallpaperManager") 26 | 27 | // Defines event names that the module can send to JavaScript. 28 | Events("onChange") 29 | 30 | AsyncFunction("setWallpaper") { path: String -> 31 | setWallpaper(context, path) 32 | } 33 | 34 | AsyncFunction("deleteWallpaper") { path: String -> 35 | deleteWallpaper(context, path) 36 | } 37 | } 38 | 39 | /** 40 | * Set wallpaper provided by path 41 | */ 42 | @SuppressLint("MissingPermission") 43 | private fun setWallpaper(context: Context, filepath: String) { 44 | try { 45 | // Set wallpaper 46 | val wallpaperManager = WallpaperManager.getInstance(context) 47 | 48 | val options: BitmapFactory.Options = BitmapFactory.Options() 49 | options.inPreferredConfig = Bitmap.Config.ARGB_8888 50 | 51 | MediaScannerConnection.scanFile(context, arrayOf(filepath), null 52 | ) { path, uri -> 53 | try { 54 | val source: ImageDecoder.Source = ImageDecoder.createSource(context.contentResolver, uri) 55 | val bitmap: Bitmap = ImageDecoder.decodeBitmap(source); 56 | wallpaperManager.setBitmap(bitmap) 57 | this@WallpaperManagerModule.sendEvent( 58 | "onChange", bundleOf( 59 | "success" to true, 60 | "path" to path 61 | ) 62 | ) 63 | } catch (e: Exception) { 64 | Log.e("WallpaperManagerModule", "Attempted to set wallpaper: $path") 65 | e.printStackTrace() 66 | this@WallpaperManagerModule.sendEvent( 67 | "onChange", bundleOf( 68 | "success" to false, 69 | "path" to path 70 | ) 71 | ) 72 | throw e 73 | } 74 | } 75 | } catch (e: IOException) { 76 | Log.e("WallpaperManagerModule", "An error occurred while setting wallpaper") 77 | e.printStackTrace() 78 | throw e 79 | } 80 | } 81 | 82 | /** 83 | * Delete wallpaper provided by path 84 | */ 85 | private fun deleteWallpaper(context: Context, path: String): Boolean { 86 | var result = false 87 | try { 88 | val uri = getImageUri(context, path) 89 | if (uri != null) { 90 | try { 91 | result = context.contentResolver.delete(uri, null, null) > 0 92 | } catch (e: Exception) { 93 | Log.e("WallpaperManagerModule", "Attempted to delete wallpaper: $path") 94 | e.printStackTrace() 95 | throw e 96 | } 97 | } 98 | } catch (e: Exception) { 99 | Log.e("WallpaperManagerModule", "An error occurred while deleting wallpaper") 100 | e.printStackTrace() 101 | throw e 102 | } 103 | return result 104 | } 105 | 106 | private fun getImageUri(context: Context, path: String): Uri? { 107 | val cursor = context.contentResolver.query( 108 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 109 | arrayOf(MediaStore.Images.Media._ID), 110 | MediaStore.Images.Media.DATA + " = ?", 111 | arrayOf(path), 112 | null 113 | ) 114 | val uri = if (cursor != null && cursor.moveToFirst()) 115 | ContentUris.withAppendedId( 116 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 117 | cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)) 118 | ) else null 119 | cursor?.close() 120 | return uri 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import {Tabs} from "expo-router"; 2 | import React from "react"; 3 | import {FolderDown, Home, LucideIcon, Search, Settings} from "lucide-react-native"; 4 | import {View} from "react-native"; 5 | import {Button, ButtonText} from "@/components/ui/Button"; 6 | import {NavigationHelpers, ParamListBase, TabNavigationState} from "@react-navigation/native"; 7 | import {LinearGradient} from "expo-linear-gradient"; 8 | import {Colors} from "@/constants/colors"; 9 | import ChangeLogDialog from "@/components/ChangeLog"; 10 | import {useSettingsStore} from "@/store/settings"; 11 | import Constants from "expo-constants"; 12 | import {SafeAreaView} from "react-native-safe-area-context"; 13 | import PrivacyPolicyDialog from "@/components/PrivacyPolicyDialog"; 14 | import {PRIVACY_POLICY_VERSION} from "@/appconfig"; 15 | 16 | const TABS: { 17 | [key: string]: {name: string; Icon: LucideIcon; sort: number}; 18 | } = { 19 | index: {name: "Home", Icon: Home, sort: 0}, 20 | search: {name: "Search", Icon: Search, sort: 1}, 21 | downloaded: {name: "Downloads", Icon: FolderDown, sort: 2}, 22 | settings: {name: "Settings", Icon: Settings, sort: 3}, 23 | }; 24 | 25 | export default function TabLayout() { 26 | const store = useSettingsStore(); 27 | const version = Constants.expoConfig?.version ?? "Unknown"; 28 | 29 | return ( 30 | 31 | } 33 | screenOptions={{ 34 | headerShown: false, 35 | }} 36 | /> 37 | { 40 | store.setPrivacyPolicyAcceptedVersion(PRIVACY_POLICY_VERSION); 41 | }} 42 | /> 43 | { 46 | store.setChangelogLastViewed(version); 47 | }} 48 | /> 49 | 50 | ); 51 | } 52 | 53 | function TabBar({ 54 | state, 55 | descriptors, 56 | navigation, 57 | }: { 58 | state: TabNavigationState; 59 | descriptors: any; 60 | navigation: NavigationHelpers; 61 | }) { 62 | const [tabs, setTabs] = React.useState([]); 63 | 64 | // Produce JSX for the tabs 65 | React.useEffect(() => { 66 | setTabs([]); 67 | let tabList: {Tab: React.JSX.Element; label: string}[] = []; 68 | for (let index = 0; index < state.routes.length; index++) { 69 | const route = state.routes[index]; 70 | const {options} = descriptors[route.key]; 71 | const label = 72 | options.tabBarLabel !== undefined 73 | ? options.tabBarLabel 74 | : options.title !== undefined 75 | ? options.title 76 | : route.name; 77 | 78 | const isFocused = state.index === index; 79 | 80 | const onPress = () => { 81 | navigation.emit({ 82 | type: "tabPress", 83 | target: route.key, 84 | canPreventDefault: true, 85 | }); 86 | if (!isFocused) { 87 | navigation.navigate(route.name); 88 | } 89 | }; 90 | 91 | const Icon = TABS[label].Icon; 92 | const TabBtn = ( 93 | 101 | 102 | 106 | {TABS[label].name} 107 | 108 | 109 | ); 110 | tabList.push({Tab: TabBtn, label: label}); 111 | } 112 | // Sort 113 | tabList.sort((a, b) => { 114 | return TABS[a.label].sort - TABS[b.label].sort; 115 | }); 116 | setTabs(tabList.map(s => s.Tab)); 117 | }, [state]); 118 | 119 | return ( 120 | <> 121 | 122 | 123 | {tabs.map(tab => { 124 | return tab; 125 | })} 126 | 127 | 128 | > 129 | ); 130 | } 131 | -------------------------------------------------------------------------------- /lib/utils/process_reddit_post.ts: -------------------------------------------------------------------------------- 1 | import {WallpaperImageType, WallpaperPostType} from "../services/wallpaper_type"; 2 | import {WALLPAPER_MIN_ALLOWED_HEIGHT, WALLPAPER_MIN_ALLOWED_WIDTH} from "@/appconfig"; 3 | import {WALLPAPERS_URL} from "@/constants/wallpaper_options"; 4 | import urlJoin from "url-join"; 5 | 6 | /** 7 | * Return the processed post data 8 | * @param post 9 | * @returns 10 | */ 11 | export function processRedditPost(post: any): WallpaperPostType[] | null { 12 | const posts: WallpaperPostType[] = []; 13 | 14 | if (post === undefined) { 15 | throw new Error("Post is undefined"); 16 | } 17 | 18 | // Skip post if it meets certain conditions 19 | if (skipPost(post)) return null; 20 | // if post has 'gallery_data' field, it means it's an album 21 | if (post.gallery_data) { 22 | const files = post.media_metadata; 23 | const file_ids = Object.keys(files); 24 | for (let i = 0; i < file_ids.length; i++) { 25 | const file = file_ids[i]; 26 | const resolutions = files[file].p; 27 | const source = files[file].s; 28 | const source_url = `https://i.redd.it/${file}.png`; 29 | // check if image size is appropriate 30 | if (source.x < WALLPAPER_MIN_ALLOWED_WIDTH || source.y < WALLPAPER_MIN_ALLOWED_HEIGHT) { 31 | continue; 32 | } 33 | // Construct the image object 34 | const image: WallpaperImageType = { 35 | url: source_url, 36 | preview_url: 37 | resolutions.length > 0 ? htmlDecode(resolutions[Math.max(resolutions.length - 3, 0)].u) : undefined, // get the 3rd last resolution 38 | preview_small_url: 39 | resolutions.length > 0 ? htmlDecode(resolutions[Math.max(resolutions.length - 4, 0)].u) : undefined, // get the 4th last resolution 40 | width: source.x, 41 | height: source.y, 42 | }; 43 | // Construct the post object 44 | const wallpaperPost: WallpaperPostType = { 45 | id: `${post.id}#${file}`, 46 | image: image, 47 | flair: post.link_flair_text, 48 | title: `${removeParenthesisData(post.title).replace(/[^\x00-\x7F]/g, "")} (${i + 1})`, // remove non-ascii characters 49 | created_utc: new Date(post.created_utc * 1000), // convert to milliseconds 50 | domain: post.domain, 51 | score: post.score, 52 | over_18: post.over_18, 53 | author: post.author, 54 | author_flair: post.author_flair_text, 55 | postlink: "https://reddit.com" + post.permalink, 56 | comments: post.num_comments, 57 | comments_link: urlJoin(WALLPAPERS_URL, "comments", post.id + ".json"), 58 | }; 59 | posts.push(wallpaperPost); 60 | } 61 | } else { 62 | // check if images exist 63 | if (!post.preview || !post.preview.images || post.preview.images.length === 0) { 64 | return null; 65 | } 66 | 67 | const resolutions = post.preview.images[0].resolutions; 68 | const source = post.preview.images[0].source; 69 | 70 | if (resolutions === undefined || source === undefined) { 71 | return null; 72 | } 73 | 74 | // check if image size is appropriate 75 | if (source.width < WALLPAPER_MIN_ALLOWED_WIDTH || source.height < WALLPAPER_MIN_ALLOWED_HEIGHT) { 76 | return null; 77 | } 78 | 79 | const image: WallpaperImageType = { 80 | url: htmlDecode(post.url), 81 | preview_url: 82 | resolutions.length > 0 ? htmlDecode(resolutions[Math.max(resolutions.length - 4, 0)].url) : undefined, // get the 3rd last resolution 83 | preview_small_url: 84 | resolutions.length > 0 ? htmlDecode(resolutions[Math.max(resolutions.length - 5, 0)].url) : undefined, // get the 4th last resolution 85 | width: source.width, 86 | height: source.height, 87 | }; 88 | 89 | // Construct the post object 90 | const wallpaperPost: WallpaperPostType = { 91 | id: post.id, 92 | image: image, 93 | flair: post.link_flair_text, 94 | title: removeParenthesisData(post.title).replace(/[^\x00-\x7F]/g, ""), // remove non-ascii characters 95 | created_utc: new Date(post.created_utc * 1000), // convert to milliseconds 96 | domain: post.domain, 97 | score: post.score, 98 | over_18: post.over_18, 99 | author: post.author, 100 | author_flair: post.author_flair_text, 101 | postlink: "https://reddit.com" + post.permalink, 102 | comments: post.num_comments, 103 | comments_link: urlJoin(WALLPAPERS_URL, "comments", post.id + ".json"), 104 | }; 105 | posts.push(wallpaperPost); 106 | } 107 | 108 | return posts; 109 | } 110 | 111 | /** 112 | * Decide whether to skip a post based on certain conditions 113 | * @param post 114 | * @returns 115 | */ 116 | export function skipPost(post: {over_18: any; title: string; link_flair_text?: string; url: string}) { 117 | const isImage = 118 | post.url.endsWith(".jpg") || 119 | post.url.endsWith(".png") || 120 | post.url.endsWith(".jpeg") || 121 | post.url.includes("/gallery/"); 122 | if (!isImage) return true; 123 | 124 | const isBlacklisted = 125 | post.over_18 || 126 | post.title.toLowerCase().includes("request") || 127 | post.title.toLowerCase().includes("question") || 128 | post.title.toLowerCase().includes("fuck") || 129 | post.link_flair_text?.toLowerCase().includes("meta") || 130 | post.link_flair_text?.toLowerCase().includes("psa"); 131 | 132 | if (isBlacklisted) return true; 133 | 134 | return false; 135 | } 136 | 137 | /** 138 | * Decode HTML. E.g. & to & 139 | */ 140 | export function htmlDecode(input: string): string { 141 | let output = input.replace(/&/g, "&"); 142 | return output; 143 | } 144 | 145 | /** 146 | * Clean titles by removing parenthesis and data within 147 | */ 148 | export function removeParenthesisData(input: string): string { 149 | return input.replace(/[\[\(].*?[\]\)]/g, "").trim(); 150 | } 151 | -------------------------------------------------------------------------------- /assets/images/tail.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | -------------------------------------------------------------------------------- /assets/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /store/settings.ts: -------------------------------------------------------------------------------- 1 | import {SEARCH_HISTORY_LIMIT} from "@/appconfig"; 2 | import {SortOptions} from "@/constants/sort_options"; 3 | import generateUUID from "@/lib/utils/uuid"; 4 | import AsyncStorage from "@react-native-async-storage/async-storage"; 5 | import {create} from "zustand"; 6 | 7 | export interface SettingsStore { 8 | initialize: () => Promise; 9 | deviceIdentifier: string; 10 | downloadDir: string | null; 11 | setDownloadDir: (dir: string | null) => void; 12 | homeSort: SortOptions; 13 | setHomeSort: (sort: SortOptions) => void; 14 | downloadedScreenSort: "Old to New" | "New to Old"; 15 | setDownloadedScreenSort: (sort: "Old to New" | "New to Old") => void; 16 | searchHistory: string[]; 17 | addSearchHistory: (query: string) => void; 18 | clearSearchHistory: () => void; 19 | isDailyWallpaperEnabled: boolean; 20 | setDailyWallpaperEnabled: (enabled: boolean) => void; 21 | dailyWallpaperMode: "online" | "downloaded"; 22 | setDailyWallpaperMode: (mode: "online" | "downloaded") => void; 23 | dailyWallpaperSort: SortOptions; 24 | setDailyWallpaperSort: (sort: SortOptions) => void; 25 | isLowerThumbnailQualityEnabled: boolean; 26 | setLowerThumbnailQualityEnabled: (enabled: boolean) => void; 27 | rememberSortPreferences: boolean; 28 | setRememberedSortPreferences: (e: boolean) => void; 29 | rememberSearchHistory: boolean; 30 | setRememberedSearchHistory: (e: boolean) => void; 31 | 32 | // Error Logs 33 | sendErrorLogsEnabled: boolean; 34 | setSendErrorLogsEnabled: (e: boolean) => void; 35 | logsLastSent: string | null; 36 | setLogsLastSent: (date: Date | null) => void; 37 | 38 | // Privacy Policy 39 | PrivacyPolicyAcceptedVersion: string | null; 40 | setPrivacyPolicyAcceptedVersion: (version: string | null) => void; 41 | 42 | // Changelog 43 | changelogLastViewed: string | null; 44 | setChangelogLastViewed: (version: string) => void; 45 | } 46 | 47 | export const useSettingsStore = create((set, get) => ({ 48 | initialize: async () => { 49 | const settings = await getSettings(); 50 | let device_identifier = settings.deviceIdentifier; 51 | if (settings.deviceIdentifier === null || settings.deviceIdentifier === undefined) { 52 | // Generate a new device identifier 53 | device_identifier = generateUUID(); 54 | const newSettings = {...settings, deviceIdentifier: device_identifier}; 55 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 56 | } 57 | set({ 58 | deviceIdentifier: device_identifier, 59 | downloadDir: settings.downloadDir, 60 | homeSort: settings.homeSort || SortOptions.Hot, 61 | downloadedScreenSort: settings.downloadedScreenSort || "New to Old", 62 | searchHistory: settings.searchHistory || [], 63 | isDailyWallpaperEnabled: settings.isDailyWallpaperEnabled || false, 64 | dailyWallpaperMode: settings.dailyWallpaperMode || "online", 65 | dailyWallpaperSort: settings.dailyWallpaperSort || SortOptions.Hot, 66 | isLowerThumbnailQualityEnabled: settings.isLowerThumbnailQualityEnabled || false, 67 | rememberSortPreferences: settings.rememberSortPreferences || false, 68 | rememberSearchHistory: settings.rememberSearchHistory || true, 69 | sendErrorLogsEnabled: settings.sendErrorLogsEnabled || true, 70 | logsLastSent: settings.logsLastSent || null, 71 | PrivacyPolicyAcceptedVersion: settings.PrivacyPolicyAcceptedVersion || null, 72 | changelogLastViewed: settings.changelogLastViewed || null, 73 | }); 74 | }, 75 | 76 | deviceIdentifier: "", 77 | 78 | downloadDir: null, 79 | setDownloadDir: async (dir: string | null) => { 80 | const currentSettings = await getSettings(); 81 | const newSettings = {...currentSettings, downloadDir: dir}; 82 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 83 | set(newSettings); 84 | }, 85 | 86 | homeSort: SortOptions.Hot, 87 | setHomeSort: async (sort: SortOptions) => { 88 | const currentSettings = await getSettings(); 89 | const newSettings = {...currentSettings, homeSort: sort}; 90 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 91 | set(newSettings); 92 | }, 93 | 94 | downloadedScreenSort: "New to Old", 95 | setDownloadedScreenSort: async (sort: string) => { 96 | const currentSettings = await getSettings(); 97 | const newSettings = {...currentSettings, downloadedScreenSort: sort}; 98 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 99 | set(newSettings); 100 | }, 101 | 102 | searchHistory: [], 103 | addSearchHistory: async (query: string) => { 104 | const history = get().searchHistory.filter(q => q !== query); 105 | history.unshift(query); 106 | const limit = SEARCH_HISTORY_LIMIT; 107 | if (history.length > limit) history.splice(limit); 108 | const currentSettings = await getSettings(); 109 | const newSettings = {...currentSettings, searchHistory: history}; 110 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 111 | set(newSettings); 112 | }, 113 | clearSearchHistory: async () => { 114 | const currentSettings = await getSettings(); 115 | const newSettings = {...currentSettings, searchHistory: []}; 116 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 117 | set(newSettings); 118 | }, 119 | 120 | isDailyWallpaperEnabled: false, 121 | setDailyWallpaperEnabled: async (enabled: boolean) => { 122 | const currentSettings = await getSettings(); 123 | const newSettings = {...currentSettings, isDailyWallpaperEnabled: enabled}; 124 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 125 | set(newSettings); 126 | }, 127 | dailyWallpaperMode: "online", 128 | setDailyWallpaperMode: async (mode: string) => { 129 | const currentSettings = await getSettings(); 130 | const newSettings = {...currentSettings, dailyWallpaperMode: mode}; 131 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 132 | set(newSettings); 133 | }, 134 | dailyWallpaperSort: SortOptions.Hot, 135 | setDailyWallpaperSort: async (sort: SortOptions) => { 136 | const currentSettings = await getSettings(); 137 | const newSettings = {...currentSettings, dailyWallpaperSort: sort}; 138 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 139 | set(newSettings); 140 | }, 141 | 142 | isLowerThumbnailQualityEnabled: false, 143 | setLowerThumbnailQualityEnabled: async (enabled: boolean) => { 144 | const currentSettings = await getSettings(); 145 | const newSettings = {...currentSettings, isLowerThumbnailQualityEnabled: enabled}; 146 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 147 | set(newSettings); 148 | }, 149 | 150 | rememberSortPreferences: false, 151 | setRememberedSortPreferences: async (e: boolean) => { 152 | const currentSettings = await getSettings(); 153 | const newSettings = {...currentSettings, rememberSortPreferences: e}; 154 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 155 | set(newSettings); 156 | }, 157 | 158 | rememberSearchHistory: false, 159 | setRememberedSearchHistory: async (e: boolean) => { 160 | const currentSettings = await getSettings(); 161 | const newSettings = {...currentSettings, rememberSearchHistory: e}; 162 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 163 | set(newSettings); 164 | }, 165 | 166 | sendErrorLogsEnabled: true, 167 | setSendErrorLogsEnabled: async (e: boolean) => { 168 | const currentSettings = await getSettings(); 169 | const newSettings = {...currentSettings, sendErrorLogsEnabled: e}; 170 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 171 | set(newSettings); 172 | }, 173 | logsLastSent: null, 174 | setLogsLastSent: async (date: Date | null) => { 175 | const currentSettings = await getSettings(); 176 | const newSettings = {...currentSettings, logsLastSent: date ? date.toISOString().toString() : null}; 177 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 178 | set(newSettings); 179 | }, 180 | 181 | PrivacyPolicyAcceptedVersion: null, 182 | setPrivacyPolicyAcceptedVersion: async (version: string | null) => { 183 | const currentSettings = await getSettings(); 184 | const newSettings = {...currentSettings, PrivacyPolicyAcceptedVersion: version}; 185 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 186 | set(newSettings); 187 | }, 188 | 189 | changelogLastViewed: null, 190 | setChangelogLastViewed: async (version: string) => { 191 | const currentSettings = await getSettings(); 192 | const newSettings = {...currentSettings, changelogLastViewed: version}; 193 | await AsyncStorage.setItem("settings", JSON.stringify(newSettings)); 194 | set(newSettings); 195 | }, 196 | })); 197 | 198 | const getSettings = async () => { 199 | return (await AsyncStorage.getItem("settings").then(result => (result ? JSON.parse(result) : {}))) || {}; 200 | }; 201 | -------------------------------------------------------------------------------- /app/(tabs)/settings.tsx: -------------------------------------------------------------------------------- 1 | import {Pressable, ScrollView, ToastAndroid, View} from "react-native"; 2 | import React from "react"; 3 | import {Text} from "@/components/ui/Text"; 4 | import TopBar from "@/components/ui/TopBar"; 5 | import {Switch} from "@/components/ui/Switch"; 6 | import Constants from "expo-constants"; 7 | import * as WebBrowser from "expo-web-browser"; 8 | import {useSettingsStore} from "@/store/settings"; 9 | import Select from "@/components/ui/Select"; 10 | import {SortOptions} from "@/constants/sort_options"; 11 | import {PLAY_STORE_URL, PRIVACY_POLICY_URL, SEARCH_HISTORY_LIMIT} from "@/appconfig"; 12 | import PlayStoreIcon from "@/assets/icons/play_store.svg"; 13 | import { 14 | hasPermissionForStorage, 15 | openAppInDeviceSettings, 16 | requestStoragePermissionsAsync, 17 | } from "@/modules/download-manager"; 18 | import {Button} from "@/components/ui/Button"; 19 | import { 20 | changeDailyWallpaperSort, 21 | changeDailyWallpaperType, 22 | registerDailyWallpaperService, 23 | unregisterDailyWallpaperService, 24 | } from "@/modules/dailywallpaper"; 25 | import {getURIFromSort} from "../../lib/services/get_wallpapers"; 26 | import ChangeLogDialog from "@/components/ChangeLog"; 27 | import * as SqlUtility from "@/lib/utils/sql"; 28 | 29 | export default function SettingsScreen() { 30 | const store = useSettingsStore(); 31 | const DAILY_WALLPAPER_MODES = ["Online", "Downloaded"]; 32 | const [showChangeLog, setShowChangeLog] = React.useState(false); 33 | 34 | return ( 35 | <> 36 | 37 | { 42 | await WebBrowser.openBrowserAsync(PLAY_STORE_URL); 43 | }}> 44 | Rate us on 45 | 46 | Play Store 47 | 48 | 49 | 50 | 51 | 52 | { 57 | store.setDailyWallpaperEnabled(e); 58 | if (e) { 59 | try { 60 | await registerDailyWallpaperService( 61 | store.dailyWallpaperMode, 62 | getURIFromSort(store.dailyWallpaperSort), 63 | ); 64 | } catch (err) { 65 | SqlUtility.insertErrorLog( 66 | { 67 | file: "(tabs)/settings.tsx[SettingsScreen]", 68 | description: "Failed to enable daily wallpaper", 69 | error_title: "Daily Wallpaper Error", 70 | method: "JSX SettingSwitchComponent", 71 | params: JSON.stringify({}), 72 | severity: "error", 73 | stacktrace: typeof err === "string" ? err : "", 74 | }, 75 | store.deviceIdentifier, 76 | ); 77 | console.error(err); 78 | ToastAndroid.showWithGravity("Failed to enable", ToastAndroid.SHORT, ToastAndroid.CENTER); 79 | store.setDailyWallpaperEnabled(false); 80 | } 81 | } else { 82 | await unregisterDailyWallpaperService(); 83 | } 84 | }} 85 | /> 86 | 87 | Select mode 88 | 89 | { 93 | if (e === "downloadeded") { 94 | if (!hasPermissionForStorage()) { 95 | await requestStoragePermissionsAsync(); 96 | } 97 | if (!hasPermissionForStorage()) { 98 | ToastAndroid.showWithGravity("Permission denied", ToastAndroid.SHORT, ToastAndroid.CENTER); 99 | return; 100 | } 101 | } 102 | store.setDailyWallpaperMode(e.toLowerCase() as any); 103 | changeDailyWallpaperType(e.toLowerCase() === "online" ? "online" : "downloaded"); 104 | }} 105 | width={140} 106 | /> 107 | 108 | {store.dailyWallpaperMode === "online" && ( 109 | 110 | { 114 | store.setDailyWallpaperSort(SortOptions[e as keyof typeof SortOptions]); 115 | changeDailyWallpaperSort(getURIFromSort(SortOptions[e as keyof typeof SortOptions])); 116 | }} 117 | width={140} 118 | /> 119 | 120 | )} 121 | 122 | 123 | { 128 | store.setLowerThumbnailQualityEnabled(e); 129 | }} 130 | /> 131 | 132 | { 137 | store.setRememberedSortPreferences(e); 138 | }} 139 | /> 140 | 141 | { 146 | store.setRememberedSearchHistory(e); 147 | }} 148 | /> 149 | 150 | { 157 | openAppInDeviceSettings(); 158 | }} 159 | /> 160 | 161 | { 166 | store.setSendErrorLogsEnabled(e); 167 | }} 168 | /> 169 | 170 | { 173 | setShowChangeLog(true); 174 | }}> 175 | Changelog 176 | 177 | 178 | { 181 | await WebBrowser.openBrowserAsync(PRIVACY_POLICY_URL); 182 | }}> 183 | Privacy Policy 184 | 185 | 186 | 187 | 188 | Version {Constants.expoConfig?.version ?? "Unknown"}{" "} 189 | {Constants.expoConfig?.extra?.commit && `(${Constants.expoConfig?.extra?.commit.slice(0, 7)})`} 190 | {` ${process.env.EXPO_PUBLIC_BUILD_NAME ?? "BUILD_UNKNOWN"}`} 191 | 192 | ID — {store.deviceIdentifier} 193 | 194 | 195 | 196 | 197 | { 200 | setShowChangeLog(false); 201 | }} 202 | /> 203 | > 204 | ); 205 | } 206 | 207 | function SettingSwitchComponent({ 208 | title, 209 | description, 210 | isEnabled, 211 | onChange, 212 | }: { 213 | title: string; 214 | description: string; 215 | isEnabled: boolean; 216 | onChange: (e: boolean) => void; 217 | }) { 218 | return ( 219 | onChange(!isEnabled)}> 220 | 221 | 222 | {title} 223 | {description} 224 | 225 | 226 | 227 | 228 | 229 | 230 | ); 231 | } 232 | -------------------------------------------------------------------------------- /app/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import {FlatList, View} from "react-native"; 2 | import {Text} from "@/components/ui/Text"; 3 | import React from "react"; 4 | import {getWallpapers} from "@/lib/services/get_wallpapers"; 5 | import {useMutation} from "@tanstack/react-query"; 6 | import {PaginationType, WallpaperPostType} from "@/lib/services/wallpaper_type"; 7 | import {CircleX} from "lucide-react-native"; 8 | import {Button, ButtonText} from "@/components/ui/Button"; 9 | import LoadingSpinner from "@/components/ui/LoadingSpinner"; 10 | import Animated, {useSharedValue, withTiming} from "react-native-reanimated"; 11 | import OnlineWallpaperGridItem from "@/components/OnlineWallpaperGridItem"; 12 | import {fadingPulseAnimation} from "@/lib/animations/fading_pulse"; 13 | import {useSettingsStore} from "@/store/settings"; 14 | import {SortOptions} from "@/constants/sort_options"; 15 | import Select from "@/components/ui/Select"; 16 | import TopBar from "@/components/ui/TopBar"; 17 | import * as SqlUtility from "@/lib/utils/sql"; 18 | import SendErrorLogs from "@/lib/services/send_error_logs"; 19 | 20 | type PostsType = { 21 | posts: WallpaperPostType[]; 22 | pagination: PaginationType; 23 | } | null; 24 | 25 | export default function HomeScreen() { 26 | const store = useSettingsStore(); 27 | const flatListRef = React.useRef(null); 28 | const [posts, setPosts] = React.useState(null); 29 | const [sort, setSort] = React.useState(store.rememberSortPreferences ? store.homeSort : SortOptions.Hot); 30 | 31 | // Animations 32 | const topBarAnimateTop = useSharedValue(0); 33 | const topBarAnimateOpacity = useSharedValue(1); 34 | 35 | // Lock to prevent multiple fetches 36 | const [isMutationLock, setIsMutationLock] = React.useState(false); 37 | 38 | // Fetch wallpapers logic 39 | const wallpaperMutation = useMutation({ 40 | mutationKey: ["wallpapers", sort], 41 | mutationFn: () => { 42 | if (posts && posts.pagination.after === null) { 43 | return Promise.reject("[SafeError, EndOfPosts] No more to fetch."); 44 | } 45 | if (isMutationLock) { 46 | return Promise.reject("[SafeError, Lock] Mutation is locked."); 47 | } 48 | // Lock the mutation 49 | setIsMutationLock(true); 50 | // Fetch wallpapers 51 | return getWallpapers( 52 | sort, 53 | posts?.pagination.after, 54 | posts?.pagination.page_number ? posts.pagination.page_number + 1 : 1, 55 | store.deviceIdentifier, 56 | ); 57 | }, 58 | onSuccess: data => { 59 | // Concat if we are not on the first page 60 | setPosts(prev => ({ 61 | posts: 62 | (posts?.pagination.page_number ?? 0) > 0 && prev !== null && prev !== undefined 63 | ? prev.posts.concat(data.posts) 64 | : data.posts, 65 | pagination: data.pagination, 66 | })); 67 | setIsMutationLock(false); 68 | }, 69 | onError: error => { 70 | // Log error 71 | if (!error.toString().toLowerCase().includes("safeerror") && error.message !== "Network Error") { 72 | SqlUtility.insertErrorLog( 73 | { 74 | file: "(tabs)/index.tsx[HomeScreen]", 75 | description: error.message, 76 | error_title: "Wallpaper Fetch Error", 77 | method: "wallpaperMutation", 78 | params: JSON.stringify({ 79 | sort: sort, 80 | pagination: posts?.pagination, 81 | }), 82 | severity: "error", 83 | stacktrace: error.stack || "", 84 | }, 85 | store.deviceIdentifier, 86 | ); 87 | } 88 | setIsMutationLock(false); 89 | }, 90 | }); 91 | 92 | // When sort is changed, change states 93 | function onSortChange(sort: SortOptions) { 94 | if (flatListRef.current) { 95 | flatListRef.current.scrollToOffset({animated: true, offset: 0}); 96 | } 97 | setPosts(null); // empty out posts 98 | setSort(sort); // set new sort 99 | store.setHomeSort(sort); // store 100 | } 101 | 102 | // Trigger wallpaper fetch on sort change 103 | React.useEffect(() => { 104 | wallpaperMutation.mutate(); 105 | }, [sort]); 106 | 107 | return ( 108 | <> 109 | 110 | 111 | 112 | { 116 | onSortChange(SortOptions[e as keyof typeof SortOptions]); 117 | }} 118 | width={140} 119 | /> 120 | 121 | 122 | {wallpaperMutation.isError && wallpaperMutation.error.message?.includes("SafeError") && ( 123 | 124 | 125 | 126 | Oh no! 127 | 128 | 129 | We encountered some problem while loading wallpapers. Please try again. 130 | 131 | wallpaperMutation.mutate()}> 132 | Retry 133 | 134 | 135 | )} 136 | {/* TODO: on sort change, scroll to top */} 137 | item.id} 141 | data={posts?.posts} 142 | onEndReached={() => { 143 | if (!wallpaperMutation.isPending) { 144 | wallpaperMutation.mutate(); 145 | } 146 | }} 147 | onScroll={e => { 148 | if (e.nativeEvent.contentOffset.y > 96 && e.nativeEvent.velocity && e.nativeEvent.velocity.y > 0) { 149 | topBarAnimateTop.value = withTiming(-72, {duration: 200}); 150 | topBarAnimateOpacity.value = withTiming(0, {duration: 200}); 151 | } else { 152 | topBarAnimateTop.value = withTiming(0, {duration: 200}); 153 | topBarAnimateOpacity.value = withTiming(1, {duration: 200}); 154 | } 155 | }} 156 | onEndReachedThreshold={0.5} 157 | className="z-0 w-full px-3 pt-20" 158 | columnWrapperClassName="gap-3" 159 | contentContainerClassName="gap-3" 160 | renderItem={({item}) => } 161 | ListFooterComponent={() => { 162 | if (posts && posts.pagination.after === null) { 163 | return ( 164 | 165 | End of posts for current filter 166 | 167 | ); 168 | } else if ((posts?.pagination.page_number ?? 0) > 0) { 169 | return wallpaperMutation.isPending ? ( 170 | 171 | 172 | 173 | Loading more... 174 | 175 | 176 | ) : wallpaperMutation.isError ? ( 177 | 178 | wallpaperMutation.mutate()} /> 179 | 180 | ) : ( 181 | <>> 182 | ); 183 | } 184 | }} 185 | /> 186 | {/* LOADING SPINNER */} 187 | {wallpaperMutation.isPending && posts === null && ( 188 | <> 189 | {/* show in center of screen */} 190 | 191 | 192 | Loading... 193 | 194 | > 195 | )} 196 | {wallpaperMutation.isError && posts === null && ( 197 | <> 198 | {/* show in center of screen */} 199 | 200 | wallpaperMutation.mutate()} /> 201 | 202 | > 203 | )} 204 | 205 | 206 | > 207 | ); 208 | } 209 | 210 | function ErrorFetching({reload}: {reload: () => void}) { 211 | return ( 212 | <> 213 | {/* show in center of screen */} 214 | 215 | Error occured while loading wallpapers... 216 | Make sure you are connected to the internet. 217 | 218 | Retry 219 | 220 | > 221 | ); 222 | } 223 | 224 | function SendLogs({isSendLogsEnabled}: {isSendLogsEnabled: boolean}) { 225 | const store = useSettingsStore(); 226 | 227 | React.useEffect(() => { 228 | const lastDateSent: string | null = store.logsLastSent; 229 | const currentDate = new Date(); 230 | const diffDays = lastDateSent 231 | ? (currentDate.getTime() - new Date(lastDateSent).getTime()) / (1000 * 60 * 60 * 24) 232 | : 1; 233 | 234 | async function send() { 235 | if (isSendLogsEnabled && diffDays >= 1) { 236 | const success = await SendErrorLogs(isSendLogsEnabled); 237 | if (success) { 238 | store.setLogsLastSent(currentDate); 239 | } 240 | } 241 | } 242 | 243 | send(); 244 | }, []); 245 | 246 | return <>>; 247 | } 248 | -------------------------------------------------------------------------------- /app/(tabs)/search.tsx: -------------------------------------------------------------------------------- 1 | import {FlatList, TextInput, View} from "react-native"; 2 | import React from "react"; 3 | import {Text} from "@/components/ui/Text"; 4 | import OnlineWallpaperGridItem from "@/components/OnlineWallpaperGridItem"; 5 | import {PaginationType, WallpaperPostType} from "@/lib/services/wallpaper_type"; 6 | import {useMutation} from "@tanstack/react-query"; 7 | import {getWallpapersFromSearch} from "@/lib/services/search_wallpapers"; 8 | import {Input} from "@/components/ui/Input"; 9 | import useDebounce from "@/hooks/useDebounce"; 10 | import {CircleX, Search, SearchX} from "lucide-react-native"; 11 | import LoadingSpinner from "@/components/ui/LoadingSpinner"; 12 | import {LinearGradient} from "expo-linear-gradient"; 13 | import {useSettingsStore} from "@/store/settings"; 14 | import {Button, ButtonText} from "@/components/ui/Button"; 15 | import {fadingPulseAnimation} from "@/lib/animations/fading_pulse"; 16 | import Animated from "react-native-reanimated"; 17 | import * as SqlUtility from "@/lib/utils/sql"; 18 | 19 | type PostsType = { 20 | posts: WallpaperPostType[]; 21 | pagination: PaginationType; 22 | } | null; 23 | 24 | export default function SearchScreen() { 25 | const [posts, setPosts] = React.useState(null); 26 | const [query, setQuery] = React.useState(""); 27 | const inputRef = React.useRef(null); 28 | const debouncedQuery = useDebounce(query, query.length > 2 ? 500 : 0); 29 | 30 | const store = useSettingsStore(); 31 | 32 | // Lock to prevent multiple fetches 33 | const [isMutationLock, setIsMutationLock] = React.useState(false); 34 | 35 | // Focus input on mount 36 | React.useEffect(() => { 37 | if (inputRef.current) inputRef.current.focus(); 38 | }, []); 39 | 40 | // Process debounced query 41 | React.useEffect(() => { 42 | if (debouncedQuery.length < 3) { 43 | setPosts({posts: [], pagination: {after: undefined, page_number: 0}}); 44 | wallpaperMutation.reset(); 45 | return; 46 | } 47 | wallpaperMutation.mutate(debouncedQuery); 48 | if (store.rememberSearchHistory) { 49 | store.addSearchHistory(debouncedQuery); 50 | } 51 | }, [debouncedQuery]); 52 | 53 | // Fetch wallpapers logic 54 | const wallpaperMutation = useMutation({ 55 | mutationKey: ["wallpaper_search"], 56 | mutationFn: (query: string) => { 57 | if (posts?.posts && posts?.posts.length > 0 && posts.pagination.after === null) { 58 | return Promise.reject("[SafeError, EndOfPosts] No more to fetch."); 59 | } 60 | if (isMutationLock) { 61 | return Promise.reject("[SafeError, Lock] Mutation is locked."); 62 | } 63 | // Lock the mutation 64 | setIsMutationLock(true); 65 | // Fetch wallpapers 66 | return getWallpapersFromSearch( 67 | query, 68 | (posts?.pagination.page_number ?? 0) + 1, 69 | posts?.pagination.after, 70 | store.deviceIdentifier, 71 | ); 72 | }, 73 | onSuccess: data => { 74 | setPosts(prev => ({ 75 | posts: 76 | (posts?.pagination.page_number ?? 0) > 0 && prev !== null && prev !== undefined 77 | ? prev.posts.concat(data.posts) 78 | : data.posts, 79 | pagination: data.pagination, 80 | })); 81 | setIsMutationLock(false); 82 | }, 83 | onError: error => { 84 | // Log error 85 | if (!error.toString().toLowerCase().includes("safeerror") && error.message !== "Network Error") { 86 | SqlUtility.insertErrorLog( 87 | { 88 | file: "(tabs)/search.tsx[SearchScreen]", 89 | description: error.message, 90 | error_title: "Wallpaper Fetch Error", 91 | method: "wallpaperMutation", 92 | params: JSON.stringify({ 93 | query: query, 94 | pagination: posts?.pagination, 95 | }), 96 | severity: "error", 97 | stacktrace: error.stack || error.toString() || "", 98 | }, 99 | store.deviceIdentifier, 100 | ); 101 | } 102 | setIsMutationLock(false); 103 | }, 104 | }); 105 | 106 | return ( 107 | <> 108 | 109 | 110 | 111 | 112 | 113 | setQuery(text)} 117 | showClearButton={true} 118 | value={query} 119 | placeholder="Type something..." 120 | placeholderTextColor={"#787878"} 121 | /> 122 | 123 | 124 | 125 | {debouncedQuery.length < 3 ? ( 126 | <> 127 | {store.rememberSearchHistory && ( 128 | 129 | Recent searches 130 | {store.searchHistory.length > 0 && ( 131 | <> 132 | {store.searchHistory.map(q => ( 133 | setQuery(q)}> 134 | {q} 135 | 136 | ))} 137 | store.clearSearchHistory()}> 138 | 139 | Clear history 140 | 141 | 142 | > 143 | )} 144 | 145 | )} 146 | > 147 | ) : wallpaperMutation.isSuccess && posts?.posts.length === 0 ? ( 148 | 149 | 150 | No results found for '{debouncedQuery}' 151 | 152 | ) : ( 153 | <>> 154 | )} 155 | item.id} 158 | data={posts?.posts} 159 | onEndReached={() => { 160 | if (!wallpaperMutation.isPending && debouncedQuery.length > 2) { 161 | wallpaperMutation.mutate(debouncedQuery); 162 | } 163 | }} 164 | className="z-0 w-full px-3 pt-20" 165 | columnWrapperClassName="gap-3" 166 | contentContainerClassName="gap-3" 167 | renderItem={({item}) => } 168 | ListHeaderComponent={() => 169 | posts?.posts && posts.posts.length > 0 ? ( 170 | 171 | Results for '{debouncedQuery}' 172 | 173 | ) : ( 174 | <>> 175 | ) 176 | } 177 | ListFooterComponent={() => { 178 | if (posts?.posts && posts.posts.length > 0 && posts.pagination.after === null) { 179 | return ( 180 | 181 | End of posts for current filter 182 | 183 | ); 184 | } else if ( 185 | wallpaperMutation.isPending && 186 | (posts?.pagination.page_number ?? 0) > 0 && 187 | posts?.pagination.after != null 188 | ) { 189 | return ( 190 | 191 | 192 | 193 | Loading more... 194 | 195 | 196 | ); 197 | } else if (wallpaperMutation.isError && (posts?.posts.length ?? 0) > 0) { 198 | return ( 199 | 200 | wallpaperMutation.mutate(debouncedQuery)} /> 201 | 202 | ); 203 | } 204 | }} 205 | /> 206 | {wallpaperMutation.isPending && (posts === null || posts.posts.length === 0) ? ( 207 | /* LOADING SPINNER */ 208 | <> 209 | 210 | 211 | Loading... 212 | 213 | > 214 | ) : wallpaperMutation.isError && (posts === null || posts.posts.length === 0) ? ( 215 | /* Error message */ 216 | <> 217 | 218 | wallpaperMutation.mutate(debouncedQuery)} /> 219 | 220 | > 221 | ) : posts === null || posts.posts.length === 0 ? ( 222 | /* Type in to search */ 223 | <> 224 | 225 | 226 | Type in the search box above to get started 227 | 228 | > 229 | ) : ( 230 | <>> 231 | )} 232 | 233 | > 234 | ); 235 | } 236 | 237 | function ErrorFetching({reload}: {reload: () => void}) { 238 | return ( 239 | <> 240 | {/* show in center of screen */} 241 | 242 | Error occured while loading wallpapers... 243 | Make sure you are connected to the internet. 244 | 245 | Retry 246 | 247 | > 248 | ); 249 | } 250 | -------------------------------------------------------------------------------- /app/downloaded_viewer.tsx: -------------------------------------------------------------------------------- 1 | import {Button, ButtonText} from "@/components/ui/Button"; 2 | import {Text} from "@/components/ui/Text"; 3 | import * as WallpaperManager from "@/modules/wallpaper-manager"; 4 | import {useDownloadedWallpapersStore} from "@/store/downloaded_wallpapers"; 5 | import {LinearGradient} from "expo-linear-gradient"; 6 | import {ArrowLeft, CheckCircle, ExternalLink, ImageIcon, Maximize2, MoreVertical, Trash2} from "lucide-react-native"; 7 | import * as React from "react"; 8 | import {Dimensions, Image, Pressable, ToastAndroid, View} from "react-native"; 9 | import Carousel from "react-native-reanimated-carousel"; 10 | import * as SqlUtility from "@/lib/utils/sql"; 11 | import {useSettingsStore} from "@/store/settings"; 12 | import LoadingSpinner from "@/components/ui/LoadingSpinner"; 13 | import {useLocalSearchParams, useRouter} from "expo-router"; 14 | import {SafeAreaView} from "react-native-safe-area-context"; 15 | import Animated, { 16 | FadeInDown, 17 | FadeInUp, 18 | FadeOutDown, 19 | FadeOutUp, 20 | SharedValue, 21 | interpolate, 22 | useAnimatedStyle, 23 | useSharedValue, 24 | withTiming, 25 | } from "react-native-reanimated"; 26 | import {useEvent} from "expo"; 27 | import * as WebBrowser from "expo-web-browser"; 28 | 29 | type WallpaperApplyState = "idle" | "applying" | "applied" | "error"; 30 | 31 | export default function DownloadedViewer() { 32 | const WallpaperChangeListener = useEvent(WallpaperManager.Module, WallpaperManager.ChangeEvent); 33 | const params = useLocalSearchParams(); 34 | const [currentIndex, setCurrentIndex] = React.useState(0); 35 | const [applyState, setApplyState] = React.useState("idle"); 36 | const [isOptionsMenuOpen, setIsOptionsMenuOpen] = React.useState(false); 37 | const width = Dimensions.get("window").width; 38 | const height = Dimensions.get("window").height; 39 | const DownloadedStore = useDownloadedWallpapersStore(); 40 | const settingStore = useSettingsStore(); 41 | const router = useRouter(); 42 | const isWallpaperOrderReversed = settingStore.downloadedScreenSort === "New to Old"; 43 | const wallpapers = isWallpaperOrderReversed ? DownloadedStore.files.slice().reverse() : DownloadedStore.files; 44 | const currentWallpaper = wallpapers[currentIndex]; 45 | 46 | React.useEffect(() => { 47 | setCurrentIndex(wallpapers.findIndex(file => file.path === params["path"])); 48 | }, []); 49 | 50 | // Wallpaper change listener 51 | React.useEffect(() => { 52 | // wallpaper change listener 53 | const wallpaper_change_event = WallpaperChangeListener as WallpaperManager.ChangeEventType | null; 54 | if (wallpaper_change_event !== null) { 55 | setApplyState(wallpaper_change_event.success ? "applied" : "error"); 56 | if (!wallpaper_change_event.success) { 57 | ToastAndroid.show("Failed to apply wallpaper", ToastAndroid.SHORT); 58 | } 59 | } 60 | }, [WallpaperChangeListener]); 61 | 62 | // Apply wallpaper 63 | const applyWallpaper = async (path: string) => { 64 | try { 65 | setApplyState("applying"); 66 | await WallpaperManager.setWallpaper(path as string); 67 | } catch (error) { 68 | // Log error 69 | SqlUtility.insertErrorLog( 70 | { 71 | file: "downloaded_viewer.tsx[DownloadedViewer]", 72 | description: "Error while applying wallpaper", 73 | error_title: error instanceof Error ? error.name : "Applying wallpaper fail", 74 | method: "applyWallpaper", 75 | params: JSON.stringify({ 76 | fileSystemPath: path, 77 | }), 78 | severity: "error", 79 | stacktrace: error instanceof Error ? error.stack || error.message : "", 80 | }, 81 | settingStore.deviceIdentifier, 82 | ); 83 | } 84 | }; 85 | 86 | // Animations 87 | const pressAnim = useSharedValue(0); 88 | const animationStyle = React.useCallback((value: number) => { 89 | "worklet"; 90 | const zIndex = interpolate(value, [-1, 0, 1], [-20, 0, 20]); 91 | const translateX = interpolate(value, [-1, 0, 1], [-width, 0, width]); 92 | return { 93 | transform: [{translateX}], 94 | zIndex, 95 | }; 96 | }, []); 97 | const buttonStyle = useAnimatedStyle(() => { 98 | const scale = interpolate(Math.abs(pressAnim.value - 1), [0, 1], [1, 1]); 99 | const opacity = interpolate(Math.abs(pressAnim.value - 1), [0, 1], [0, 1]); 100 | return { 101 | opacity, 102 | transform: [{scale}], 103 | }; 104 | }, []); 105 | 106 | const deleteWallpaper = async () => { 107 | const e = await WallpaperManager.deleteWallpaper(currentWallpaper.path); 108 | if (e) { 109 | if (currentIndex === 0 && wallpapers.length === 1) { 110 | router.back(); 111 | } else if (currentIndex === wallpapers.length - 1) { 112 | setCurrentIndex(currentIndex - 1); 113 | } else { 114 | setCurrentIndex(currentIndex + 1); 115 | } 116 | DownloadedStore.removeFile(currentWallpaper.path); 117 | setIsOptionsMenuOpen(false); 118 | } else { 119 | ToastAndroid.show("Failed to delete wallpaper", ToastAndroid.SHORT); 120 | } 121 | }; 122 | 123 | return ( 124 | 125 | { 135 | pressAnim.value = withTiming(1); 136 | }} 137 | onScrollEnd={() => { 138 | pressAnim.value = withTiming(0); 139 | }} 140 | onSnapToItem={index => { 141 | setCurrentIndex(index); 142 | setApplyState("idle"); 143 | }} 144 | defaultIndex={currentIndex} 145 | renderItem={item => } 146 | /> 147 | 148 | 151 | 152 | {currentWallpaper.title.trim()} 153 | {currentWallpaper.width && currentWallpaper.height && ( 154 | 155 | 156 | 157 | {currentWallpaper.width} x {currentWallpaper.height} 158 | 159 | 160 | )} 161 | 162 | 163 | {applyState !== "applied" ? ( 164 | applyWallpaper(currentWallpaper.path)} 167 | className="rounded-full"> 168 | 169 | Apply 170 | 171 | ) : applyState === "applied" ? ( 172 | 173 | 174 | Applied 175 | 176 | ) : ( 177 | 178 | 179 | 180 | )} 181 | 182 | 183 | 184 | 185 | 186 | 187 | router.back()}> 192 | 193 | 194 | 195 | 196 | Viewing {currentIndex + 1} of {wallpapers.length} 197 | 198 | 199 | 200 | setIsOptionsMenuOpen(true)}> 205 | 206 | 207 | {isOptionsMenuOpen && ( 208 | 209 | 210 | { 214 | await WebBrowser.openBrowserAsync( 215 | "https://www.reddit.com/r/Amoledbackgrounds/comments/" + currentWallpaper.id, 216 | ); 217 | }}> 218 | 219 | See on Reddit 220 | 221 | 225 | 226 | 227 | Delete 228 | 229 | 230 | 231 | 232 | )} 233 | 234 | 235 | 236 | 237 | {/* catch outside presses */} 238 | setIsOptionsMenuOpen(false)} 240 | className={`${!isOptionsMenuOpen && "hidden"} absolute -top-96 -right-96 z-20 w-[200vh] h-[200vh]`} 241 | /> 242 | 243 | ); 244 | } 245 | 246 | const CarouselItem = ({ 247 | path, 248 | width, 249 | height, 250 | pressAnim, 251 | }: { 252 | path: string; 253 | width: number; 254 | height: number; 255 | pressAnim: SharedValue; 256 | }) => { 257 | const itemStyle = useAnimatedStyle(() => { 258 | const scale = interpolate(pressAnim.value, [0, 1], [1, 0.9]); 259 | const borderRadius = interpolate(pressAnim.value, [0, 1], [0, 30]); 260 | 261 | return { 262 | transform: [{scale}], 263 | borderRadius, 264 | }; 265 | }, []); 266 | 267 | return ( 268 | 269 | 270 | 271 | ); 272 | }; 273 | -------------------------------------------------------------------------------- /modules/download-manager/android/src/main/java/com/nzran/downloadmanager/DownloadManagerModule.kt: -------------------------------------------------------------------------------- 1 | package com.nzran.downloadmanager 2 | 3 | import android.annotation.SuppressLint 4 | import expo.modules.kotlin.modules.Module 5 | import expo.modules.kotlin.modules.ModuleDefinition 6 | import expo.modules.interfaces.permissions.Permissions 7 | import expo.modules.kotlin.Promise 8 | 9 | import android.app.DownloadManager 10 | import android.content.BroadcastReceiver 11 | import android.content.ContentResolver 12 | import android.content.ContentValues 13 | import android.content.Context 14 | import android.content.Intent 15 | import android.content.IntentFilter 16 | import android.content.pm.PackageManager 17 | import android.media.MediaScannerConnection 18 | import android.net.Uri 19 | import android.os.Build 20 | import android.os.Environment 21 | import android.provider.MediaStore 22 | import android.util.Log 23 | import androidx.core.os.bundleOf 24 | import java.util.concurrent.Executors 25 | import android.os.Handler 26 | import android.os.Looper 27 | import android.os.Message 28 | import androidx.core.database.getStringOrNull 29 | import expo.modules.kotlin.exception.Exceptions 30 | 31 | import java.io.File 32 | import java.nio.file.Files 33 | import java.util.ArrayList 34 | import java.util.HashMap 35 | 36 | class DownloadManagerModule : Module() { 37 | 38 | private val context 39 | get() = requireNotNull(appContext.reactContext) 40 | 41 | private val downloadLocation = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES); 42 | 43 | private val executor = Executors.newSingleThreadExecutor() 44 | var isDownloadComplete = false 45 | 46 | private val downloadProgressHandler = Handler(Looper.getMainLooper()) { msg -> 47 | if (msg.what == 1) { 48 | this@DownloadManagerModule.sendEvent("onDownloadProgress", msg.data) 49 | } 50 | true 51 | } 52 | 53 | private val permissionsManager: Permissions 54 | get() = appContext.permissions ?: throw Exceptions.PermissionsModuleNotFound() 55 | 56 | // Each module class must implement the definition function. The definition consists of components 57 | // that describes the module's functionality and behavior. 58 | // See https://docs.expo.dev/modules/module-api for more details about available components. 59 | @SuppressLint("Range") 60 | override fun definition() = ModuleDefinition { 61 | Name("DownloadManager") 62 | 63 | 64 | // Download image function 65 | Function("downloadImage") { url: String, filename: String, fileExtension: String -> 66 | downloadImage(context, url, filename, fileExtension) 67 | } 68 | 69 | // Event to notify 70 | Events("onDownloadComplete", "onDownloadProgress") 71 | 72 | // Function to get downloaded files 73 | Function("getDownloadedFiles") { matchNameStr: String -> 74 | val result = ArrayList>() 75 | val contentResolver: ContentResolver = context.contentResolver 76 | val projection = arrayOf( 77 | MediaStore.Images.Media.DISPLAY_NAME, 78 | MediaStore.Images.Media.HEIGHT, 79 | MediaStore.Images.Media.WIDTH 80 | ) 81 | val selection = "" 82 | val selectionArgs = arrayOf() 83 | val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC" 84 | 85 | try { 86 | val cursor = contentResolver.query( 87 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 88 | projection, 89 | selection, 90 | selectionArgs, 91 | sortOrder 92 | ) 93 | if (cursor != null) { 94 | while (cursor.moveToNext()) { 95 | val name = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)) 96 | if (matchNameStr.isNotEmpty() && !name.contains(matchNameStr)) { 97 | continue 98 | } 99 | val fileMap = HashMap() 100 | fileMap["name"] = name 101 | fileMap["path"] = downloadLocation.resolve(name).absolutePath 102 | fileMap["width"] = cursor.getStringOrNull(cursor.getColumnIndex(MediaStore.Images.Media.WIDTH)) ?: "" 103 | fileMap["height"] = cursor.getStringOrNull(cursor.getColumnIndex(MediaStore.Images.Media.HEIGHT)) ?: "" 104 | result.add(fileMap) 105 | } 106 | cursor.close() 107 | } 108 | } catch (e: Exception) { 109 | Log.e("DownloadManagerModule", "Failed to get downloaded files", e) 110 | } 111 | result 112 | } 113 | 114 | // Permissions 115 | // Check if the app has permission to read external storage 116 | Function("hasPermissionForStorage") { 117 | hasPermissionForStorage() 118 | } 119 | // Request permission to read external storage 120 | AsyncFunction("requestStoragePermissionsAsync") { promise: Promise -> 121 | val permission: String = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { 122 | android.Manifest.permission.READ_EXTERNAL_STORAGE 123 | } else { 124 | android.Manifest.permission.READ_MEDIA_IMAGES 125 | } 126 | Permissions.askForPermissionsWithPermissionsManager( 127 | permissionsManager, 128 | promise, 129 | permission 130 | ) 131 | } 132 | // Open App page in settings to allow permission 133 | Function("openAppInDeviceSettings") { 134 | val intent = Intent() 135 | intent.action = android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS 136 | intent.data = Uri.fromParts("package", context.packageName, null) 137 | intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) 138 | context.startActivity(intent) 139 | } 140 | 141 | // Check if file exists 142 | AsyncFunction("checkFileExists") { path: String -> 143 | val file = File(path) 144 | return@AsyncFunction file.exists() 145 | } 146 | } 147 | 148 | /** 149 | * 150 | */ 151 | private fun hasPermissionForStorage(): Boolean { 152 | var permission = android.Manifest.permission.READ_EXTERNAL_STORAGE 153 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 154 | permission = android.Manifest.permission.READ_MEDIA_IMAGES 155 | } 156 | if (context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED) { 157 | return true 158 | } 159 | return false 160 | } 161 | 162 | /** 163 | * Download image from given URL 164 | */ 165 | var downloadId: Long = 0 166 | private var filename: String = "" 167 | private var fileExtension: String = "" 168 | 169 | @SuppressLint("Range", "UnspecifiedRegisterReceiverFlag") 170 | fun downloadImage(context: Context, url: String, filename: String, fileExtension: String): Long { 171 | val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 172 | val downloadUri = Uri.parse(url) 173 | this.filename = filename 174 | this.fileExtension = fileExtension 175 | this.isDownloadComplete = false 176 | 177 | val request = DownloadManager.Request(downloadUri) 178 | request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) 179 | request.setAllowedOverRoaming(false) 180 | request.setTitle("Downloading $filename") 181 | request.setDescription("Downloading image from $url") 182 | request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) 183 | request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, "$filename.download") 184 | 185 | downloadId = downloadManager.enqueue(request) 186 | // register receiver to listen for download completion 187 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 188 | context.registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), Context.RECEIVER_EXPORTED) 189 | } else { 190 | context.registerReceiver(downloadReceiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) 191 | } 192 | // setup executor to track download progress 193 | executor.execute { 194 | while (!isDownloadComplete && downloadId != 0L) { 195 | try { 196 | val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) 197 | if (cursor != null && cursor.moveToFirst()) { 198 | val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) 199 | if (status == DownloadManager.STATUS_SUCCESSFUL) { 200 | isDownloadComplete = true 201 | } 202 | 203 | val bytesDownloaded = 204 | cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)) 205 | val bytesTotal = 206 | cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)) 207 | val progress = if (bytesTotal > 0) (bytesDownloaded * 100L / bytesTotal).toInt() else 0 208 | 209 | val message = Message.obtain() 210 | message.what = 1 211 | message.data = bundleOf( 212 | "progress" to progress, 213 | "filename" to filename, 214 | "downloadId" to downloadId 215 | ) 216 | downloadProgressHandler.sendMessage(message) 217 | cursor.close() 218 | } else { 219 | isDownloadComplete = true 220 | } 221 | } catch (e: Exception) { 222 | Log.e("DownloadManagerModule", "Failed to track download progress", e) 223 | } 224 | Thread.sleep(200) 225 | } 226 | } 227 | return downloadId 228 | } 229 | 230 | // Broadcast receiver to listen for download completion 231 | private val downloadReceiver = object : BroadcastReceiver() { 232 | override fun onReceive(context: Context, intent: Intent) { 233 | val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) 234 | if (id == downloadId) { 235 | isDownloadComplete = true 236 | sendDownloadedFileInfo() 237 | // unregister receiver 238 | context.unregisterReceiver(this) 239 | } 240 | } 241 | } 242 | 243 | // Send event to JavaScript after processing downloaded file 244 | fun sendDownloadedFileInfo() { 245 | try { 246 | var file = File(downloadLocation,"$filename.download") 247 | if (!file.exists()) { 248 | throw Exception("Downloaded file not found") 249 | } 250 | // rename file to remove .download extension 251 | Files.move(file.toPath(), file.toPath().resolveSibling("$filename.$fileExtension")) 252 | file = File(downloadLocation, "$filename.$fileExtension") 253 | // Remove from MediaStore 254 | val contentResolver: ContentResolver = context.contentResolver 255 | val contentUri: Uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI 256 | val selection: String = MediaStore.Images.Media.DISPLAY_NAME + " = ?" 257 | contentResolver.delete(contentUri, selection, arrayOf("$filename.download")) 258 | // Add renamed file to MediaStore 259 | val values = ContentValues() 260 | values.put(MediaStore.Images.Media.DISPLAY_NAME, "$filename.$fileExtension") 261 | MediaScannerConnection.scanFile(context, arrayOf(file.absolutePath), null 262 | ) { _, uri -> 263 | val success = context.contentResolver.update(uri, values, null, null) == 1 264 | if (!success) { 265 | throw Exception("Failed to update renamed image in MediaStore") 266 | } 267 | } 268 | this@DownloadManagerModule.sendEvent("onDownloadComplete", bundleOf( 269 | "success" to true, 270 | "path" to file.absolutePath 271 | )) 272 | } catch (e: Exception) { 273 | Log.e("DownloadManagerModule", "Failed to download image", e) 274 | // TODO: send error to JavaScript or handle it here 275 | 276 | // send event to JavaScript 277 | this@DownloadManagerModule.sendEvent("onDownloadComplete", bundleOf( 278 | "success" to false, 279 | "path" to "" 280 | )) 281 | } 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /modules/dailywallpaper/android/src/main/java/com/nzran/dailywallpaper/Utils.kt: -------------------------------------------------------------------------------- 1 | package com.nzran.dailywallpaper 2 | 3 | import android.annotation.SuppressLint 4 | import android.app.DownloadManager 5 | import android.app.Notification 6 | import android.app.WallpaperManager 7 | import android.content.ContentResolver 8 | import android.content.Context 9 | import android.graphics.Bitmap 10 | import android.graphics.BitmapFactory 11 | import android.graphics.ImageDecoder 12 | import android.media.MediaScannerConnection 13 | import android.net.Uri 14 | import android.os.Environment 15 | import android.provider.MediaStore 16 | import android.util.Base64 17 | import android.util.Log 18 | import androidx.core.app.NotificationCompat 19 | import androidx.core.graphics.drawable.IconCompat 20 | import androidx.preference.PreferenceManager 21 | import org.json.JSONObject 22 | import java.io.IOException 23 | import java.net.HttpURLConnection 24 | import java.net.URL 25 | import java.util.ArrayList 26 | import java.util.Scanner 27 | 28 | class Utils { 29 | companion object { 30 | 31 | /** 32 | * Get a wallpaper based on the type 33 | * @param context The context 34 | * @param type The type of wallpaper to get 35 | * @param sort The sort parameter for online wallpapers 36 | * @return Any of the following: 37 | *- If type is online and wallpaper is downloading, returns a HashMap with downloadId and path. 38 | *- If type is online and wallpaper is downloaded, returns the path of the wallpaper. 39 | *- If type is downloaded, returns the path of the wallpaper. 40 | *- If invalid, throws IllegalArgumentException. 41 | */ 42 | fun getWallpaper(context: Context, type: String, sort: String = "new.json"): Any? { 43 | if (type == "online") { 44 | // get online wallpaper 45 | val wallpaper = getWallpaperFromReddit(sort) 46 | if (wallpaper != null) { 47 | val location = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES) 48 | val file = location?.resolve("${wallpaper["title"]!!}.${wallpaper["extension"]!!}") 49 | if (file != null) { 50 | if (file.exists()) { 51 | Log.d("DailyWallpaperUtils", "Wallpaper already downloaded") 52 | return file.absolutePath // already downloaded 53 | } else { 54 | val item = HashMap() 55 | // start downloading the wallpaper and return the download id 56 | item["downloadId"] = downloadWallpaper( 57 | context, 58 | wallpaper["url"]!!, 59 | wallpaper["title"]!!, 60 | wallpaper["extension"]!! 61 | ) 62 | item["path"] = file.absolutePath 63 | return item 64 | } 65 | } 66 | } 67 | return null 68 | } 69 | return getWallpaperFromStorage(context) 70 | } 71 | 72 | @SuppressLint("MissingPermission") 73 | fun setWallpaper(context: Context, filepath: String) { 74 | // Set wallpaper 75 | val wallpaperManager = WallpaperManager.getInstance(context) 76 | 77 | val options: BitmapFactory.Options = BitmapFactory.Options() 78 | options.inPreferredConfig = Bitmap.Config.ARGB_8888 79 | 80 | MediaScannerConnection.scanFile(context, arrayOf(filepath), null 81 | ) { path, uri -> 82 | try { 83 | val source: ImageDecoder.Source = 84 | ImageDecoder.createSource(context.contentResolver, uri) 85 | val bitmap: Bitmap = ImageDecoder.decodeBitmap(source) 86 | wallpaperManager.setBitmap(bitmap) 87 | } catch (e: Exception) { 88 | Log.e("WallpaperManagerModule", "Attempted to set wallpaper: $path") 89 | e.printStackTrace() 90 | } 91 | } 92 | } 93 | 94 | fun pushNotification(context: Context, channel: String, message: String, iconUri: String): Notification { 95 | val decodedBytes: ByteArray = Base64.decode(iconUri, Base64.DEFAULT) 96 | val notification = NotificationCompat.Builder(context, channel) 97 | .setContentTitle("Daily Wallpaper") 98 | .setContentText(message) 99 | .setPriority(NotificationCompat.PRIORITY_DEFAULT) 100 | .setAutoCancel(true) 101 | .setContentIntent(null) 102 | .setSmallIcon(IconCompat.createWithBitmap(BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.size))) 103 | .build() 104 | return notification 105 | } 106 | 107 | private fun doesFileExist(filepath: String): Boolean { 108 | try { 109 | // check if the wallpaper exists 110 | val file = java.io.File(filepath) 111 | return file.exists() 112 | } catch (e: Exception) { 113 | Log.e("DailyWallpaperUtils", "Failed to check if file exists", e) 114 | return false 115 | } 116 | } 117 | 118 | private fun downloadWallpaper(context: Context, url: String, title: String, extension: String): Long { 119 | val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager 120 | val downloadUri = Uri.parse(url) 121 | 122 | val request = DownloadManager.Request(downloadUri) 123 | request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE) 124 | request.setAllowedOverRoaming(false) 125 | request.setTitle("Downloading $title") 126 | request.setDescription("Downloading image from $url") 127 | request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) 128 | request.setDestinationInExternalPublicDir(Environment.DIRECTORY_PICTURES, "$title.$extension") 129 | 130 | return downloadManager.enqueue(request) 131 | } 132 | 133 | private fun getWallpaperFromReddit(sort: String): HashMap? { 134 | val item = HashMap() 135 | // get wallpaper from reddit 136 | val url = "https://www.reddit.com/r/amoledbackgrounds/${sort}${if(sort.contains("?")) {"&"} else {"?"}}limit=5" 137 | Log.d("DailyWallpaper", "Fetching wallpaper from $url") 138 | val contents = jsonGetRequest(url) ?: return null 139 | 140 | // parse the json 141 | val jsonObject = JSONObject(contents.trim()) 142 | val objList = jsonObject.getJSONObject("data").getJSONArray("children") 143 | 144 | for (i in 0 until objList.length()) { 145 | val obj = objList.getJSONObject(i).getJSONObject("data") 146 | if (!obj.has("preview")) { 147 | continue 148 | } 149 | if (obj.getJSONObject("preview").getJSONArray("images").getJSONObject(0) 150 | .getJSONArray("resolutions").length() < 1) { 151 | continue 152 | } 153 | val title = obj.getString("title").trim().replace("[\\[(].*?[])]".toRegex(), "") 154 | .replace("[^\\x00-\\x7F]".toRegex(), "") 155 | .replace("\\u00A0", " ") 156 | .replace("\\s{2,}".toRegex(), " ") 157 | .replace("[/\\\\#,+()|~%'\":*?<>{}]".toRegex(), "") 158 | .trim() 159 | .replace(" ", "_") 160 | item["title"] = "${title}_-_${obj.getString("id")}_amoled_droidheat" 161 | item["url"] = obj.getString("url") 162 | item["extension"] = obj.getString("url").substringAfterLast(".") 163 | break 164 | } 165 | return if (item.containsKey("url")) { 166 | item 167 | } else { 168 | null 169 | } 170 | } 171 | 172 | private fun jsonGetRequest(urlQueryString: String): String? { 173 | var json: String? = null 174 | try { 175 | val url = URL(urlQueryString) 176 | val connection = url.openConnection() as HttpURLConnection 177 | connection.readTimeout = 10000 178 | connection.connectTimeout = 15000 179 | connection.requestMethod = "GET" 180 | connection.connect() 181 | val inStream = connection.inputStream 182 | json = Scanner(inStream, "UTF-8").useDelimiter("\\Z").next() 183 | } catch (ex: IOException) { 184 | ex.printStackTrace() 185 | } 186 | return json 187 | } 188 | 189 | /** 190 | * Get a wallpaper from the storage that has not been set in the last 14 times 191 | */ 192 | @SuppressLint("Range") 193 | fun getWallpaperFromStorage(context: Context): String? { 194 | val result = ArrayList>() 195 | val contentResolver: ContentResolver = context.contentResolver 196 | val projection = arrayOf( 197 | MediaStore.Images.Media._ID, 198 | MediaStore.Images.Media.DISPLAY_NAME, 199 | MediaStore.Images.Media.RELATIVE_PATH 200 | ) 201 | val selection = "" 202 | val selectionArgs = arrayOf() 203 | val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} ASC" 204 | 205 | try { 206 | val cursor = contentResolver.query( 207 | MediaStore.Images.Media.EXTERNAL_CONTENT_URI, 208 | projection, 209 | selection, 210 | selectionArgs, 211 | sortOrder 212 | ) 213 | if (cursor != null) { 214 | while (cursor.moveToNext()) { 215 | val name = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)) 216 | if (!name.contains("amoled_droidheat")) { 217 | continue 218 | } 219 | val path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.RELATIVE_PATH)) 220 | val fileMap = java.util.HashMap() 221 | fileMap["name"] = name 222 | fileMap["path"] = "/storage/emulated/0/$path$name" 223 | result.add(fileMap) 224 | } 225 | cursor.close() 226 | } 227 | } catch (e: Exception) { 228 | Log.e("DailyWallpaperUtils", "Failed to get downloaded files", e) 229 | } 230 | 231 | if (result.isEmpty()) { 232 | return null 233 | } 234 | 235 | // Get a wallpaper that has not been set in last 14 236 | val sharedPrefs = PreferenceManager.getDefaultSharedPreferences(context) 237 | var lastWallpaper = parseJsonToList(sharedPrefs.getString("lastWallpaper", "")!!) 238 | if (sharedPrefs.contains("lastWallpaper")) { 239 | for(i in 0 until result.size) { 240 | val hash = result[i]["path"].hashCode() 241 | if (!lastWallpaper.contains(hash)) { 242 | // remove the oldest wallpaper if the list is full 243 | if (lastWallpaper.size >= 14) { 244 | lastWallpaper = lastWallpaper.filterIndexed({ index, _ -> index != 0 }) 245 | } 246 | // add the new wallpaper to the list 247 | sharedPrefs.edit().putString("lastWallpaper", jsonStringify(lastWallpaper.plus(hash))).apply() 248 | return result[i]["path"]!! 249 | } 250 | } 251 | } 252 | // return random wallpaper 253 | val randomIndex = (0 until result.size).random() 254 | sharedPrefs.edit().putString("lastWallpaper", jsonStringify(lastWallpaper.plus(result[randomIndex]["path"].hashCode()))).apply() 255 | return result[randomIndex]["path"]!! 256 | } 257 | 258 | // Function to JSON stringify a list of integers 259 | private fun jsonStringify(intList: List): String { 260 | return "[${intList.joinToString(",")}]" 261 | } 262 | 263 | // Function to parse JSON string back to a list of integers 264 | private fun parseJsonToList(jsonString: String): List { 265 | // Remove the brackets and split by comma 266 | return jsonString.removeSurrounding("[", "]") 267 | .split(",") 268 | .mapNotNull { it -> it.trim().takeIf { it.isNotEmpty() }?.toInt() } // Convert each string to Int 269 | } 270 | 271 | } 272 | } -------------------------------------------------------------------------------- /app/(tabs)/downloaded.tsx: -------------------------------------------------------------------------------- 1 | import {Dimensions, FlatList, Image, Pressable, ToastAndroid, View} from "react-native"; 2 | import React from "react"; 3 | import {Text} from "@/components/ui/Text"; 4 | import {DownloadedWallpaperPostType, useDownloadedWallpapersStore} from "@/store/downloaded_wallpapers"; 5 | import TopBar from "@/components/ui/TopBar"; 6 | import Animated, {FadeIn, FadeInDown, FadeOutDown, useSharedValue, withTiming} from "react-native-reanimated"; 7 | import {Button, ButtonText} from "@/components/ui/Button"; 8 | import {CheckCircle, ExternalLink, ImageIcon, Maximize2, MoreVertical, Trash2} from "lucide-react-native"; 9 | import * as WallpaperManager from "@/modules/wallpaper-manager"; 10 | import {fadingPulseAnimation} from "@/lib/animations/fading_pulse"; 11 | import LoadingSpinner from "@/components/ui/LoadingSpinner"; 12 | import Select from "@/components/ui/Select"; 13 | import {useSettingsStore} from "@/store/settings"; 14 | import {hasPermissionForStorage, requestStoragePermissionsAsync} from "@/modules/download-manager"; 15 | import * as WebBrowser from "expo-web-browser"; 16 | import {useRouter} from "expo-router"; 17 | import {useEvent} from "expo"; 18 | import * as SqlUtility from "@/lib/utils/sql"; 19 | 20 | type WallpaperApplyState = { 21 | status: "idle" | "applying" | "applied" | "error"; 22 | path: string; 23 | }; 24 | 25 | export default function DownloadedWallpapersScreen() { 26 | const WallpaperChangeListener = useEvent(WallpaperManager.Module, WallpaperManager.ChangeEvent); 27 | const store = useSettingsStore(); 28 | const DownloadedWallpaperStore = useDownloadedWallpapersStore(); 29 | const settingStore = useSettingsStore(); 30 | const [applyState, setApplyState] = React.useState({status: "idle", path: ""}); 31 | const flatListRef = React.useRef(null); 32 | 33 | // Animations 34 | const topBarAnimateTop = useSharedValue(0); 35 | const topBarAnimateOpacity = useSharedValue(1); 36 | 37 | // Listeners 38 | React.useEffect(() => { 39 | // request permission for storage if we dont have it 40 | const runPermissions = async () => { 41 | if (!hasPermissionForStorage()) { 42 | await requestStoragePermissionsAsync(); 43 | await DownloadedWallpaperStore.initialize(); 44 | } 45 | }; 46 | runPermissions(); 47 | }, []); 48 | 49 | // Wallpaper change listener 50 | React.useEffect(() => { 51 | // wallpaper change listener 52 | const wallpaper_change_event = WallpaperChangeListener as WallpaperManager.ChangeEventType | null; 53 | if (wallpaper_change_event !== null) { 54 | setApplyState({status: wallpaper_change_event.success ? "applied" : "error", path: wallpaper_change_event.path}); 55 | if (!wallpaper_change_event.success) { 56 | ToastAndroid.show("Failed to apply wallpaper", ToastAndroid.SHORT); 57 | } 58 | } 59 | }, [WallpaperChangeListener]); 60 | 61 | const setWallpaper = async (wallpaper: DownloadedWallpaperPostType) => { 62 | setApplyState({status: "applying", path: wallpaper.path}); 63 | try { 64 | await WallpaperManager.setWallpaper(wallpaper.path); 65 | } catch (error) { 66 | setApplyState({status: "error", path: wallpaper.path}); 67 | SqlUtility.insertErrorLog( 68 | { 69 | file: "downloaded.tsx[DownloadedWallpaperScreen]", 70 | description: "Error while applying wallpaper", 71 | error_title: error instanceof Error ? error.name : "Error", 72 | method: "setWallpaper", 73 | params: JSON.stringify({ 74 | wallpaper: JSON.stringify(wallpaper), 75 | }), 76 | severity: "error", 77 | stacktrace: error instanceof Error ? error.stack || error.message : "", 78 | }, 79 | settingStore.deviceIdentifier, 80 | ); 81 | } 82 | }; 83 | 84 | const removeWallpaper = (wallpaper: DownloadedWallpaperPostType) => { 85 | DownloadedWallpaperStore.removeFile(wallpaper.path); 86 | }; 87 | 88 | return ( 89 | <> 90 | 91 | 92 | 93 | { 97 | store.setDownloadedScreenSort(value as any); 98 | if (flatListRef.current) flatListRef.current.scrollToOffset({offset: 0}); 99 | }} 100 | width={130} 101 | /> 102 | 103 | 104 | item.path} 108 | data={ 109 | store.downloadedScreenSort === "Old to New" 110 | ? DownloadedWallpaperStore.files 111 | : DownloadedWallpaperStore.files.slice().reverse() 112 | } 113 | className="z-0 w-full px-3 pt-20" 114 | columnWrapperClassName="gap-3" 115 | contentContainerClassName="gap-3" 116 | onScroll={e => { 117 | if (e.nativeEvent.contentOffset.y > 96 && e.nativeEvent.velocity && e.nativeEvent.velocity.y > 0) { 118 | topBarAnimateTop.value = withTiming(-72, {duration: 200}); 119 | topBarAnimateOpacity.value = withTiming(0, {duration: 200}); 120 | } else { 121 | topBarAnimateTop.value = withTiming(0, {duration: 200}); 122 | topBarAnimateOpacity.value = withTiming(1, {duration: 200}); 123 | } 124 | }} 125 | ListHeaderComponent={() => 126 | DownloadedWallpaperStore.files.length > 0 ? ( 127 | 128 | 129 | Total of {DownloadedWallpaperStore.files.length} downloaded wallpapers 130 | 131 | 132 | ) : ( 133 | <>> 134 | ) 135 | } 136 | renderItem={({item}) => ( 137 | await setWallpaper(item)} 141 | removeWallpaper={() => removeWallpaper(item)} 142 | deviceIdentifier={settingStore.deviceIdentifier} 143 | /> 144 | )} 145 | ListFooterComponent={() => ( 146 | 147 | End of posts for current filter 148 | 149 | )} 150 | /> 151 | 152 | > 153 | ); 154 | } 155 | 156 | function WallpaperGridItem({ 157 | wallpaper, 158 | applyState, 159 | applyWallpaper, 160 | removeWallpaper, 161 | deviceIdentifier, 162 | }: { 163 | wallpaper: DownloadedWallpaperPostType; 164 | applyState: "idle" | "applying" | "applied" | "error"; 165 | applyWallpaper: () => void; 166 | removeWallpaper: () => void; 167 | deviceIdentifier: string; 168 | }) { 169 | const width = Dimensions.get("window").width; 170 | const height = Dimensions.get("window").height; 171 | const [isOpen, setIsOpen] = React.useState(false); 172 | const router = useRouter(); 173 | 174 | function openDownloadedViewerScreen(path: string) { 175 | router.push({pathname: "/downloaded_viewer", params: {path: path}}); 176 | } 177 | 178 | const deleteWallpaper = async () => { 179 | try { 180 | const e = await WallpaperManager.deleteWallpaper(wallpaper.path); 181 | if (e) { 182 | removeWallpaper(); 183 | setIsOpen(false); 184 | } else { 185 | ToastAndroid.show("Failed to delete wallpaper", ToastAndroid.SHORT); 186 | } 187 | } catch (error) { 188 | SqlUtility.insertErrorLog( 189 | { 190 | file: "downloaded.tsx[WallpaperGridItem]", 191 | description: "Error while deleting wallpaper", 192 | error_title: error instanceof Error ? error.name : "Error", 193 | method: "deleteWallpaper", 194 | params: JSON.stringify({ 195 | wallpaper: JSON.stringify(wallpaper), 196 | }), 197 | severity: "error", 198 | stacktrace: error instanceof Error ? error.stack || error.message : "", 199 | }, 200 | deviceIdentifier, 201 | ); 202 | } 203 | }; 204 | 205 | return ( 206 | <> 207 | 208 | 209 | openDownloadedViewerScreen(wallpaper.path)}> 210 | 213 | 219 | {applyState === "applied" ? ( 220 | 225 | 226 | Applied 227 | 228 | ) : applyState === "applying" ? ( 229 | 234 | 235 | Applying 236 | 237 | ) : ( 238 | 243 | 244 | Set 245 | 246 | )} 247 | 248 | 249 | 250 | openDownloadedViewerScreen(wallpaper.path)}> 251 | 252 | {wallpaper.title} 253 | 254 | {wallpaper.width !== null && 255 | wallpaper.height !== null && 256 | !isNaN(wallpaper.width) && 257 | !isNaN(wallpaper.height) ? ( 258 | 259 | 260 | 261 | {wallpaper.width} x {wallpaper.height} 262 | 263 | 264 | ) : ( 265 | <>> 266 | )} 267 | 268 | 269 | 270 | { 274 | setIsOpen(v => !v); 275 | }}> 276 | 277 | 278 | {/* dropdown options */} 279 | {isOpen && ( 280 | 281 | 282 | { 286 | await WebBrowser.openBrowserAsync( 287 | "https://www.reddit.com/r/Amoledbackgrounds/comments/" + wallpaper.id, 288 | ); 289 | }}> 290 | 291 | See on Reddit 292 | 293 | 297 | 298 | 299 | Delete 300 | 301 | 302 | 303 | 304 | )} 305 | 306 | 307 | {/* catch outside presses */} 308 | setIsOpen(false)} 310 | className={`${!isOpen && "hidden"} absolute -top-96 -right-96 z-40 w-[200vh] h-[200vh]`} 311 | /> 312 | 313 | 314 | > 315 | ); 316 | } 317 | --------------------------------------------------------------------------------