├── .github ├── prompts │ └── alternate.prompt.md ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml └── workflows │ └── android-release.yml ├── fastlane └── metadata │ └── android │ └── en-US │ ├── title.txt │ ├── changelogs │ ├── 14.txt │ ├── 3.txt │ ├── 7.txt │ ├── 8.txt │ ├── 1.txt │ ├── 5.txt │ ├── 4.txt │ ├── 2.txt │ ├── 9.txt │ ├── 13.txt │ ├── 12.txt │ ├── 6.txt │ ├── 10.txt │ └── 11.txt │ ├── short_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── 1.png │ │ ├── 2.png │ │ ├── 3.png │ │ ├── 4.png │ │ └── 5.png │ └── full_description.txt ├── get-it-on-github.png ├── mockups ├── image1.png ├── image2.png ├── image3.png ├── image4.png └── image5.png ├── assets ├── icon │ ├── favicon.png │ ├── ios-dark.png │ ├── ios-light.png │ ├── ios-tinted.png │ ├── adaptive-icon.png │ ├── splash-icon-dark.png │ └── splash-icon-light.png ├── in-app-icon │ ├── telegram.png │ └── whatsapp.png └── dev-profile-photo │ └── Snake.jpg ├── get-it-on-obtainium.png ├── android ├── app │ ├── debug.keystore │ ├── src │ │ ├── main │ │ │ ├── res │ │ │ │ ├── values-night │ │ │ │ │ └── colors.xml │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_monochrome.webp │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_monochrome.webp │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_monochrome.webp │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_monochrome.webp │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ ├── ic_launcher.webp │ │ │ │ │ ├── ic_launcher_round.webp │ │ │ │ │ ├── ic_launcher_foreground.webp │ │ │ │ │ └── ic_launcher_monochrome.webp │ │ │ │ ├── drawable-hdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-mdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-xhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-xxhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-xxxhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-night-hdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-night-mdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-night-xhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-night-xxhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── drawable-night-xxxhdpi │ │ │ │ │ └── splashscreen_logo.png │ │ │ │ ├── values │ │ │ │ │ ├── colors.xml │ │ │ │ │ ├── strings.xml │ │ │ │ │ └── styles.xml │ │ │ │ ├── drawable │ │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ │ └── rn_edit_text_material.xml │ │ │ │ └── mipmap-anydpi-v26 │ │ │ │ │ ├── ic_launcher.xml │ │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── AndroidManifest.xml │ │ │ └── java │ │ │ │ └── com │ │ │ │ └── lulu786 │ │ │ │ └── Alternate │ │ │ │ ├── MainApplication.kt │ │ │ │ └── MainActivity.kt │ │ ├── release │ │ │ └── AndroidManifest.xml │ │ └── debug │ │ │ └── AndroidManifest.xml │ └── proguard-rules.pro ├── gradle │ └── wrapper │ │ ├── gradle-wrapper.jar │ │ └── gradle-wrapper.properties ├── .gitignore ├── build.gradle ├── settings.gradle ├── gradle.properties └── gradlew.bat ├── .vscode └── settings.json ├── .eas └── workflows │ └── create-production-builds.yml ├── modules └── caller-id │ ├── expo-module.config.json │ ├── index.ts │ ├── android │ ├── src │ │ └── main │ │ │ ├── res │ │ │ ├── drawable │ │ │ │ ├── circle_background.xml │ │ │ │ ├── rounded_background.xml │ │ │ │ ├── caller_card_background.xml │ │ │ │ ├── caller_info_card_background.xml │ │ │ │ ├── ic_location_24.xml │ │ │ │ ├── ic_work_24.xml │ │ │ │ ├── ic_close_24.xml │ │ │ │ └── ic_minimize_24.xml │ │ │ ├── values │ │ │ │ ├── colors.xml │ │ │ │ └── strings.xml │ │ │ ├── values-night │ │ │ │ └── colors.xml │ │ │ └── layout │ │ │ │ └── caller_info_dialog.xml │ │ │ ├── java │ │ │ └── expo │ │ │ │ └── modules │ │ │ │ └── callerid │ │ │ │ ├── database │ │ │ │ ├── CallerEntity.kt │ │ │ │ ├── CallerDao.kt │ │ │ │ └── CallerRepository.kt │ │ │ │ └── CallDetectScreeningService.kt │ │ │ └── AndroidManifest.xml │ └── build.gradle │ ├── src │ ├── CallerId.types.ts │ └── CallerIdModule.ts │ └── ios │ ├── CallerId.podspec │ ├── CallerIdView.swift │ └── CallerIdModule.swift ├── tsconfig.json ├── constants ├── AdditionalFields.ts └── Colors.ts ├── store ├── selectors.ts ├── themeStore.ts ├── selectedContactStore.ts └── contactStore.ts ├── hooks ├── useDebounce.ts ├── useTheme.ts ├── useContactSearch.ts └── useAdvancedContactSearch.ts ├── components ├── section-header.tsx ├── empty-contactsList.tsx ├── navigation-bar.tsx ├── material3-photopicker-placeholder.tsx ├── error-state.tsx ├── ErrorBoundary.tsx ├── material3-avatar.tsx ├── additional-field-sheet.tsx ├── contact-item.tsx ├── phone-number-input.tsx ├── country-selector-sheet.tsx └── photo-picker.tsx ├── .easignore ├── .gitignore ├── services └── sheets.ts ├── LICENSE ├── app ├── +not-found.tsx ├── _layout.tsx └── search.tsx ├── lib ├── permissions.ts ├── types.ts ├── utils.ts ├── avatar-utils.ts ├── search-utils.ts └── search-index.ts ├── eas.json ├── SECURITY.md ├── package.json ├── app.json ├── CONTRIBUTING.md ├── ANDROID_RELEASE_SETUP.md ├── CODE_OF_CONDUCT.md ├── SEARCH_OPTIMIZATION.md └── PRIVACY_POLICY.md /.github/prompts/alternate.prompt.md: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/title.txt: -------------------------------------------------------------------------------- 1 | Alternate -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/14.txt: -------------------------------------------------------------------------------- 1 | v2.4.6 - Bug Fixes 2 | - Fixed vcf import issue -------------------------------------------------------------------------------- /get-it-on-github.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/get-it-on-github.png -------------------------------------------------------------------------------- /mockups/image1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/mockups/image1.png -------------------------------------------------------------------------------- /mockups/image2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/mockups/image2.png -------------------------------------------------------------------------------- /mockups/image3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/mockups/image3.png -------------------------------------------------------------------------------- /mockups/image4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/mockups/image4.png -------------------------------------------------------------------------------- /mockups/image5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/mockups/image5.png -------------------------------------------------------------------------------- /assets/icon/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/favicon.png -------------------------------------------------------------------------------- /assets/icon/ios-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/ios-dark.png -------------------------------------------------------------------------------- /assets/icon/ios-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/ios-light.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/3.txt: -------------------------------------------------------------------------------- 1 | Minor bug fixes and improvements for v1.1.1 release. 2 | -------------------------------------------------------------------------------- /get-it-on-obtainium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/get-it-on-obtainium.png -------------------------------------------------------------------------------- /android/app/debug.keystore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/debug.keystore -------------------------------------------------------------------------------- /assets/icon/ios-tinted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/ios-tinted.png -------------------------------------------------------------------------------- /assets/icon/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/adaptive-icon.png -------------------------------------------------------------------------------- /assets/icon/splash-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/splash-icon-dark.png -------------------------------------------------------------------------------- /assets/in-app-icon/telegram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/in-app-icon/telegram.png -------------------------------------------------------------------------------- /assets/in-app-icon/whatsapp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/in-app-icon/whatsapp.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Local Caller ID detector. Identify unknown callers without clutter. -------------------------------------------------------------------------------- /assets/dev-profile-photo/Snake.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/dev-profile-photo/Snake.jpg -------------------------------------------------------------------------------- /assets/icon/splash-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/assets/icon/splash-icon-light.png -------------------------------------------------------------------------------- /android/app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #000000 3 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/7.txt: -------------------------------------------------------------------------------- 1 | v2.1.2 - Minor Update 2 | - Added long press to copy contact information 3 | - Minor bug fixes and stability improvements -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-hdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-mdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/1.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/2.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/3.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/4.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/fastlane/metadata/android/en-US/images/phoneScreenshots/5.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-hdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-night-hdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-mdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-night-mdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-night-xhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xxhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-night-xxhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /android/app/src/main/res/drawable-night-xxxhdpi/splashscreen_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BioHazard786/Alternate/HEAD/android/app/src/main/res/drawable-night-xxxhdpi/splashscreen_logo.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": "explicit", 4 | "source.organizeImports": "explicit", 5 | "source.sortMembers": "explicit" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/8.txt: -------------------------------------------------------------------------------- 1 | v2.2.2 - Feature Update 2 | - Added support for adding contact photos 3 | - Caller ID popup now appears on lock screen 4 | - Minor bug fixes and stability improvements -------------------------------------------------------------------------------- /android/.gitignore: -------------------------------------------------------------------------------- 1 | # OSX 2 | # 3 | .DS_Store 4 | 5 | # Android/IntelliJ 6 | # 7 | build/ 8 | .idea 9 | .gradle 10 | local.properties 11 | *.iml 12 | *.hprof 13 | .cxx/ 14 | 15 | # Bundle artifacts 16 | *.jsbundle 17 | -------------------------------------------------------------------------------- /.eas/workflows/create-production-builds.yml: -------------------------------------------------------------------------------- 1 | name: Create Production Builds 2 | 3 | jobs: 4 | build_android: 5 | type: build # This job type creates a production build for Android 6 | params: 7 | platform: android 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/1.txt: -------------------------------------------------------------------------------- 1 | Initial release of Alternate - 1.0.0 Updates: 2 | • Local caller ID detection 3 | • Privacy-focused contact storage 4 | • Material 3 UI design 5 | • Offline SQLite database 6 | • Multiple architecture support -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/5.txt: -------------------------------------------------------------------------------- 1 | v2.0.1 - Major bugfix release 2 | - Fixed critical bugs from v2.0.0 affecting stability and reliability 3 | - Improved error handling throughout the app 4 | - Enhanced overall performance and user experience -------------------------------------------------------------------------------- /modules/caller-id/expo-module.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "platforms": ["apple", "android", "web"], 3 | "apple": { 4 | "modules": ["CallerIdModule"] 5 | }, 6 | "android": { 7 | "modules": ["expo.modules.callerid.CallerIdModule"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /modules/caller-id/index.ts: -------------------------------------------------------------------------------- 1 | // Reexport the native module. On web, it will be resolved to CallerIdModule.web.ts 2 | // and on native platforms to CallerIdModule.ts 3 | export * from "./src/CallerId.types"; 4 | export { default } from "./src/CallerIdModule"; 5 | -------------------------------------------------------------------------------- /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"] 10 | } 11 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #ffffff 3 | #ffffff 4 | #023c69 5 | #ffffff 6 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/circle_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/4.txt: -------------------------------------------------------------------------------- 1 | v2.0.0 - Major Release 2 | - Added new fields for contacts to enhance information management 3 | - Introduced Material Expressive Design for a modern look and feel 4 | - Added preview screen for contacts for quick viewing 5 | - Implemented import/export functionality for contacts 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/2.txt: -------------------------------------------------------------------------------- 1 | Version 1.1.0 Updates: 2 | • Performance optimizations for smoother experience 3 | • Call Directory integration with Google Phone app 4 | • Dark mode toggle option 5 | • Caller popup UI controls 6 | • Enhanced interface and usability improvements 7 | • Bug fixes and stability improvements -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/9.txt: -------------------------------------------------------------------------------- 1 | v2.2.3 - Performance & UI Update 2 | - Optimized avatar image loading performance for faster rendering 3 | - Improved theme selection UI with streamlined button styling 4 | - Enhanced base64 image handling for better memory efficiency 5 | - Minor bug fixes and stability improvements -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/rounded_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/caller_card_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/13.txt: -------------------------------------------------------------------------------- 1 | v2.4.5 - Search Performance & Bug Fixes 2 | - Completely redesigned contact search with 60-90% performance boost 3 | - Advanced search algorithms with automatic optimization for large contact lists 4 | - Contact search now supports multi-word queries and partial matching 5 | - Resolved crash on startup 6 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/12.txt: -------------------------------------------------------------------------------- 1 | v2.4.4 - Bug Fixes & Stability Improvements 2 | - Fixed potential crash issues in caller ID detection module 3 | - Fixed UI rendering issues on certain Android devices 4 | - Enhanced compatibility with Android 14 and newer versions 5 | - Minor performance optimizations and dependency updates 6 | - Improved overall app stability and reliability -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/6.txt: -------------------------------------------------------------------------------- 1 | v2.1.1 - Feature & Improvement Release 2 | - Introduced search functionality for contacts and country selector 3 | - Added multi-delete and selection-based contact sharing features 4 | - Improved overall app performance and responsiveness 5 | - Fixed various bugs for enhanced stability 6 | - Refined and modernized user interface for a better experience -------------------------------------------------------------------------------- /android/app/src/release/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/caller_info_card_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Alternate 3 | automatic 4 | contain 5 | false 6 | 1.0.0 7 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #FFFBFE 5 | #e8def8 6 | #1C1B1F 7 | #1d192b 8 | #49454F 9 | #e8def8 10 | 11 | -------------------------------------------------------------------------------- /modules/caller-id/src/CallerId.types.ts: -------------------------------------------------------------------------------- 1 | import type { StyleProp, ViewStyle } from "react-native"; 2 | 3 | export type OnLoadEventPayload = { 4 | url: string; 5 | }; 6 | 7 | export type CallerIdModuleEvents = { 8 | onChange: (params: ChangeEventPayload) => void; 9 | }; 10 | 11 | export type ChangeEventPayload = { 12 | value: string; 13 | }; 14 | 15 | export type CallerIdViewProps = { 16 | url: string; 17 | onLoad: (event: { nativeEvent: OnLoadEventPayload }) => void; 18 | style?: StyleProp; 19 | }; 20 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | #1c1b1f 5 | #4a4458 6 | #FFFFFF 7 | #eaddff 8 | #B3B3B3 9 | #4a4458 10 | 11 | -------------------------------------------------------------------------------- /constants/AdditionalFields.ts: -------------------------------------------------------------------------------- 1 | export const additionalFields = [ 2 | { key: "prefix", label: "Prefix", icon: "account-arrow-left-outline" }, 3 | { key: "suffix", label: "Suffix", icon: "account-arrow-right-outline" }, 4 | { key: "email", label: "Email", icon: "email-outline" }, 5 | { key: "notes", label: "Notes", icon: "note-text-outline" }, 6 | { key: "website", label: "Website", icon: "link" }, 7 | { key: "birthday", label: "Birthday", icon: "cake-variant-outline" }, 8 | { key: "nickname", label: "Nickname", icon: "account-heart-outline" }, 9 | ]; 10 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/ic_location_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /store/selectors.ts: -------------------------------------------------------------------------------- 1 | import { StoreApi, UseBoundStore } from "zustand"; 2 | 3 | type WithSelectors = S extends { getState: () => infer T } 4 | ? S & { use: { [K in keyof T]: () => T[K] } } 5 | : never; 6 | 7 | const createSelectors = >>( 8 | _store: S 9 | ) => { 10 | let store = _store as WithSelectors; 11 | store.use = {}; 12 | for (let k of Object.keys(store.getState())) { 13 | (store.use as any)[k] = () => store((s) => s[k as keyof typeof s]); 14 | } 15 | 16 | return store; 17 | }; 18 | 19 | export default createSelectors; 20 | -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | function useDebounce(value: string, delay: number) { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | const [loading, setLoading] = useState(false); 6 | 7 | useEffect(() => { 8 | setLoading(true); 9 | const handler = setTimeout(() => { 10 | setDebouncedValue(value); 11 | setLoading(false); 12 | }, delay); 13 | 14 | return () => { 15 | clearTimeout(handler); 16 | setLoading(false); 17 | }; 18 | }, [value, delay]); 19 | 20 | return [debouncedValue, loading] as const; 21 | } 22 | 23 | export default useDebounce; 24 | -------------------------------------------------------------------------------- /android/app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # react-native-reanimated 11 | -keep class com.swmansion.reanimated.** { *; } 12 | -keep class com.facebook.react.turbomodule.** { *; } 13 | 14 | # Add any project specific keep options here: 15 | -keepattributes LineNumberTable -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | App Icon 4 | Close 5 | Minimize 6 | Unknown Caller 7 | 8 | _ 9 | Alternate 10 | expo.modules.callerid.directory 11 | Caller Photo 12 | 13 | -------------------------------------------------------------------------------- /components/section-header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | import { Text, useTheme } from "react-native-paper"; 4 | 5 | interface SectionHeaderProps { 6 | title: string; 7 | } 8 | 9 | export const SectionHeader: React.FC = ({ title }) => { 10 | const theme = useTheme(); 11 | return ( 12 | 13 | 14 | {title} 15 | 16 | 17 | ); 18 | }; 19 | 20 | const styles = StyleSheet.create({ 21 | sectionHeader: { 22 | paddingVertical: 8, 23 | paddingHorizontal: 16, 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /modules/caller-id/ios/CallerId.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'CallerId' 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 = { 9 | :ios => '15.1', 10 | :tvos => '15.1' 11 | } 12 | s.source = { git: '' } 13 | s.static_framework = true 14 | 15 | s.dependency 'ExpoModulesCore' 16 | 17 | # Swift/Objective-C compatibility 18 | s.pod_target_xcconfig = { 19 | 'DEFINES_MODULE' => 'YES', 20 | } 21 | 22 | s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 23 | end 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/10.txt: -------------------------------------------------------------------------------- 1 | v2.3.3 - Enhanced Caller ID & UI Improvements 2 | - Added separate popup controls for incoming and outgoing calls 3 | - Enhanced caller ID detection now supports both incoming and outgoing calls 4 | - Improved contact preview UI with modern card-based design 5 | - Updated theme selection with refined segmented buttons and color scheme 6 | - Optimized contact grouping with improved name formatting logic 7 | - Fixed location field display capitalization in edit contact screen 8 | - Enhanced phone number correction algorithm with better country detection 9 | - Improved UI consistency across contact item selections 10 | - Code quality improvements with better type safety and formatting -------------------------------------------------------------------------------- /components/empty-contactsList.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | import { Text } from "react-native-paper"; 4 | 5 | interface EmptyContactsListProps { 6 | description?: string; 7 | } 8 | 9 | export const EmptyContactsList: React.FC = ({ 10 | description, 11 | }) => { 12 | return ( 13 | 14 | No contacts found 15 | {description || "Add your first contact"} 16 | 17 | ); 18 | }; 19 | 20 | const styles = StyleSheet.create({ 21 | emptyContainer: { 22 | flex: 1, 23 | justifyContent: "center", 24 | alignItems: "center", 25 | padding: 16, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/ic_work_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [BioHazard786] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: biohazard786 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | buy_me_a_coffee: BioHazard786 13 | thanks_dev: # Replace with a single thanks.dev username 14 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 15 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/java/expo/modules/callerid/database/CallerEntity.kt: -------------------------------------------------------------------------------- 1 | package expo.modules.callerid.database 2 | 3 | import androidx.room.Entity 4 | import androidx.room.PrimaryKey 5 | 6 | @Entity(tableName = "caller_info") 7 | data class CallerEntity( 8 | @PrimaryKey 9 | val fullPhoneNumber: String, 10 | val phoneNumber: String, // Phone number without country code 11 | val countryCode: String, 12 | val name: String, 13 | val appointment: String, 14 | val location: String, // Renamed from city 15 | val iosRow: String, 16 | val suffix: String = "", 17 | val prefix: String = "", 18 | val email: String = "", 19 | val notes: String = "", 20 | val website: String = "", 21 | val birthday: String = "", 22 | val labels: String = "", 23 | val nickname: String = "", 24 | val photo: String = "" 25 | ) 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Smartphone (please complete the following information):** 28 | 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Alternate is a privacy-focused React Native app that helps you identify unknown callers without cluttering your device's main contact list. Perfect for temporary number storage when you need to know who's calling but don't want the number to appear in WhatsApp, Telegram, or other messaging apps. 2 | 3 |
Features: 4 | 5 | - Local Caller ID detection using your private database 6 | - Temporary number storage (does not affect your main contacts) 7 | - Privacy: Numbers won't appear in WhatsApp, Telegram, or other messaging apps 8 | - Phone number validation with country selection 9 | - Custom native module for caller ID (Android) 10 | - Support Call directory in google phone app 11 | - Modern Material Design 3 UI 12 | - Offline storage using SQLite database 13 | 14 | Keep your contact list clean while never missing an important call again! -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/11.txt: -------------------------------------------------------------------------------- 1 | v2.4.3 - Major UI Overhaul & Enhanced Caller ID Features 2 | - Added collapsible caller ID popup with drag-to-minimize functionality 3 | - Redesigned settings screen with improved categorization and visual layout 4 | - Enhanced avatar component with better image handling and fallback display 5 | - Improved VCF import/export with better phone number parsing and country detection 6 | - Updated to latest libphonenumber-js library for enhanced phone number validation 7 | - Migrated from ESLint to Biome for improved code quality and performance 8 | - Enhanced contact preview UI with modernized card design 9 | - Added developer profile photo assets for testing 10 | - Improved code formatting and type safety across the entire codebase 11 | - Better error handling and user experience in contact management 12 | - Performance optimizations and dependency updates -------------------------------------------------------------------------------- /.easignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | 3 | node_modules/ 4 | 5 | # Expo 6 | 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | 14 | .kotlin/ 15 | *.orig.* 16 | *.jks 17 | *.p8 18 | *.p12 19 | *.key 20 | *.mobileprovision 21 | 22 | # Metro 23 | 24 | .metro-health-check* 25 | 26 | # debug 27 | 28 | npm-debug.* 29 | yarn-debug.* 30 | yarn-error.* 31 | 32 | # macOS 33 | 34 | .DS_Store 35 | *.pem 36 | 37 | # local env files 38 | 39 | .env*.local 40 | 41 | # typescript 42 | 43 | *.tsbuildinfo 44 | 45 | # caller-id module build 46 | 47 | modules/caller-id/android/build/ 48 | 49 | # APK files (keep generated APKs out of source control) 50 | 51 | *.apk 52 | *.aab 53 | 54 | # Android build files 55 | 56 | android/app/build/ 57 | android/build/ 58 | android/.gradle/ 59 | android/local.properties 60 | 61 | # Android release files 62 | 63 | android/app/release/ 64 | 65 | # Test files 66 | 67 | test.ts 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files 2 | 3 | # dependencies 4 | node_modules/ 5 | 6 | # Expo 7 | .expo/ 8 | dist/ 9 | web-build/ 10 | expo-env.d.ts 11 | 12 | # Native 13 | .kotlin/ 14 | *.orig.* 15 | *.jks 16 | *.p8 17 | *.p12 18 | *.key 19 | *.mobileprovision 20 | 21 | # Metro 22 | .metro-health-check* 23 | 24 | # debug 25 | npm-debug.* 26 | yarn-debug.* 27 | yarn-error.* 28 | 29 | # macOS 30 | .DS_Store 31 | *.pem 32 | 33 | # local env files 34 | .env*.local 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | 39 | # caller-id module build 40 | modules/caller-id/android/build/ 41 | 42 | # APK files (keep generated APKs out of source control) 43 | *.apk 44 | *.aab 45 | 46 | # Android build files 47 | android/app/build/ 48 | android/build/ 49 | android/.gradle/ 50 | android/local.properties 51 | 52 | # Android release files 53 | android/app/release/ 54 | 55 | # Test files 56 | test.ts -------------------------------------------------------------------------------- /services/sheets.ts: -------------------------------------------------------------------------------- 1 | import AdditionalFieldSheet from "@/components/additional-field-sheet"; 2 | import CountrySelectorSheet from "@/components/country-selector-sheet"; 3 | import { Country } from "@/lib/types"; 4 | import { registerSheet, SheetDefinition } from "react-native-actions-sheet"; 5 | 6 | registerSheet("country-selector-sheet", CountrySelectorSheet); 7 | registerSheet("additional-field-sheet", AdditionalFieldSheet); 8 | 9 | // We extend some of the types here to give us great intellisense 10 | // across the app for all registered sheets. 11 | declare module "react-native-actions-sheet" { 12 | interface Sheets { 13 | "country-selector-sheet": SheetDefinition<{ 14 | payload: { 15 | selectedCountry: Country; 16 | setSelectedCountry: (country: Country) => void; 17 | onChange: (...event: any[]) => void; 18 | currentValue: any; 19 | }; 20 | }>; 21 | "additional-field-sheet": SheetDefinition<{ 22 | payload: { 23 | visibleFields: Set; 24 | setVisibleFields: (fields: Set) => void; 25 | }; 26 | }>; 27 | } 28 | } 29 | 30 | export {}; 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 BioHazard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /modules/caller-id/ios/CallerIdView.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | import WebKit 3 | 4 | // This view will be used as a native component. Make sure to inherit from `ExpoView` 5 | // to apply the proper styling (e.g. border radius and shadows). 6 | class CallerIdView: ExpoView { 7 | let webView = WKWebView() 8 | let onLoad = EventDispatcher() 9 | var delegate: WebViewDelegate? 10 | 11 | required init(appContext: AppContext? = nil) { 12 | super.init(appContext: appContext) 13 | clipsToBounds = true 14 | delegate = WebViewDelegate { url in 15 | self.onLoad(["url": url]) 16 | } 17 | webView.navigationDelegate = delegate 18 | addSubview(webView) 19 | } 20 | 21 | override func layoutSubviews() { 22 | webView.frame = bounds 23 | } 24 | } 25 | 26 | class WebViewDelegate: NSObject, WKNavigationDelegate { 27 | let onUrlChange: (String) -> Void 28 | 29 | init(onUrlChange: @escaping (String) -> Void) { 30 | self.onUrlChange = onUrlChange 31 | } 32 | 33 | func webView(_ webView: WKWebView, didFinish navigation: WKNavigation) { 34 | if let url = webView.url { 35 | onUrlChange(url.absoluteString) 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from "expo-router"; 2 | import { StyleSheet, View } from "react-native"; 3 | import { Button, Text } from "react-native-paper"; 4 | 5 | export default function NotFoundScreen() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | Page Not Found 12 | 13 | 14 | The page you're looking for doesn't exist. 15 | 16 | 17 | 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | const styles = StyleSheet.create({ 27 | container: { 28 | flex: 1, 29 | justifyContent: "center", 30 | alignItems: "center", 31 | padding: 20, 32 | }, 33 | title: { 34 | marginBottom: 10, 35 | textAlign: "center", 36 | }, 37 | subtitle: { 38 | marginBottom: 30, 39 | textAlign: "center", 40 | opacity: 0.7, 41 | }, 42 | button: { 43 | marginTop: 10, 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /lib/permissions.ts: -------------------------------------------------------------------------------- 1 | import { PermissionsAndroid, Platform } from "react-native"; 2 | import CallerIdModule from "../modules/caller-id"; 3 | 4 | export async function requestAndroidPermissions() { 5 | if (Platform.OS === "android") { 6 | // Request standard permissions together in batch 7 | const standardPermissions = await PermissionsAndroid.requestMultiple([ 8 | PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE, 9 | PermissionsAndroid.PERMISSIONS.READ_CALL_LOG, 10 | ]); 11 | 12 | // Then handle overlay permission separately (this opens system settings) 13 | if (!(await hasOverlayPermission())) { 14 | await CallerIdModule.requestOverlayPermission(); 15 | } 16 | 17 | return { 18 | readPhoneState: 19 | standardPermissions[PermissionsAndroid.PERMISSIONS.READ_PHONE_STATE], 20 | readCallLog: 21 | standardPermissions[PermissionsAndroid.PERMISSIONS.READ_CALL_LOG], 22 | systemAlertWindow: await hasOverlayPermission(), 23 | }; 24 | } 25 | return null; 26 | } 27 | 28 | export async function hasOverlayPermission() { 29 | if (Platform.OS !== "android") return true; 30 | try { 31 | return await CallerIdModule.hasOverlayPermission(); 32 | } catch { 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /modules/caller-id/src/CallerIdModule.ts: -------------------------------------------------------------------------------- 1 | import { NativeModule, requireNativeModule } from "expo"; 2 | 3 | import type { Contact } from "@/lib/types"; 4 | import type { CallerIdModuleEvents } from "./CallerId.types"; 5 | 6 | declare class CallerIdModule extends NativeModule { 7 | hasOverlayPermission(): Promise; 8 | requestOverlayPermission(): Promise; 9 | storeCallerInfo(callerData: Contact): Promise; 10 | storeMultipleCallerInfo(callerData: Contact[]): Promise; 11 | getCallerInfo(phoneNumber: string): Promise; 12 | removeCallerInfo(fullPhoneNumber: string): Promise; 13 | removeMultipleCallerInfo(fullPhoneNumbers: string[]): Promise; 14 | getAllCallerInfo(): Promise; 15 | getAllStoredNumbers(): Promise; 16 | clearAllCallerInfo(): Promise; 17 | 18 | // Settings functions 19 | setIncomingPopup(showIncomingPopup: boolean): boolean; 20 | getIncomingPopup(): boolean; 21 | setOutgoingPopup(showOutgoingPopup: boolean): boolean; 22 | getOutgoingPopup(): boolean; 23 | 24 | // Get SIM card country code 25 | getDialCountryCode(): string; 26 | } 27 | 28 | // This call loads the native module object from the JSI. 29 | export default requireNativeModule("CallerId"); 30 | -------------------------------------------------------------------------------- /android/build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath('com.android.tools.build:gradle') 10 | classpath('com.facebook.react:react-native-gradle-plugin') 11 | classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') 12 | classpath('com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.21-1.0.28') 13 | } 14 | } 15 | 16 | def reactNativeAndroidDir = new File( 17 | providers.exec { 18 | workingDir(rootDir) 19 | commandLine("node", "--print", "require.resolve('react-native/package.json')") 20 | }.standardOutput.asText.get().trim(), 21 | "../android" 22 | ) 23 | 24 | allprojects { 25 | repositories { 26 | maven { 27 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm 28 | url = reactNativeAndroidDir 29 | } 30 | 31 | google() 32 | mavenCentral() 33 | maven { url = 'https://www.jitpack.io' } 34 | 35 | tasks.withType(Zip) { 36 | reproducibleFileOrder = true 37 | preserveFileTimestamps = false 38 | } 39 | } 40 | } 41 | 42 | apply plugin: "expo-root-project" 43 | apply plugin: "com.facebook.react.rootproject" 44 | -------------------------------------------------------------------------------- /eas.json: -------------------------------------------------------------------------------- 1 | { 2 | "cli": { 3 | "version": ">= 16.6.1" 4 | }, 5 | "build": { 6 | "development": { 7 | "developmentClient": true, 8 | "distribution": "internal", 9 | "android": { 10 | "gradleCommand": ":app:assembleDebug" 11 | } 12 | }, 13 | "preview": { 14 | "distribution": "internal", 15 | "env": { 16 | "APP_VARIANT": "preview" 17 | }, 18 | "channel": "preview" 19 | }, 20 | "production": { 21 | "channel": "production", 22 | "android": { 23 | "gradleCommand": ":app:bundleRelease" 24 | } 25 | }, 26 | "production-aab": { 27 | "android": { 28 | "gradleCommand": ":app:bundleRelease" 29 | }, 30 | "env": { 31 | "EXPO_USE_FAST_RESOLVER": "1", 32 | "EXPO_ANDROID_APK_SPLIT_BY_ABI": "false" 33 | } 34 | }, 35 | "production-armv7": { 36 | "channel": "production", 37 | "android": { 38 | "buildType": "apk", 39 | "gradleCommand": ":app:assembleRelease", 40 | "env": { 41 | "EXPO_ANDROID_APK_SPLIT_BY_ABI": "true" 42 | } 43 | } 44 | }, 45 | "production-arm64": { 46 | "channel": "production", 47 | "android": { 48 | "buildType": "apk", 49 | "gradleCommand": ":app:assembleRelease", 50 | "env": { 51 | "EXPO_ANDROID_APK_SPLIT_BY_ABI": "true" 52 | } 53 | } 54 | } 55 | }, 56 | "submit": { 57 | "production": {} 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/java/expo/modules/callerid/database/CallerDao.kt: -------------------------------------------------------------------------------- 1 | package expo.modules.callerid.database 2 | 3 | import androidx.room.Dao 4 | import androidx.room.Insert 5 | import androidx.room.OnConflictStrategy 6 | import androidx.room.Query 7 | 8 | @Dao 9 | interface CallerDao { 10 | @Query("SELECT * FROM caller_info WHERE fullPhoneNumber = :phoneNumber OR phoneNumber = :phoneNumber") 11 | suspend fun getCallerInfo(phoneNumber: String): CallerEntity? 12 | 13 | @Insert(onConflict = OnConflictStrategy.REPLACE) 14 | suspend fun insertCallerInfo(callerInfo: CallerEntity) 15 | 16 | @Insert(onConflict = OnConflictStrategy.REPLACE) 17 | suspend fun insertMultipleCallerInfo(entities: List) 18 | 19 | @Query("DELETE FROM caller_info WHERE fullPhoneNumber = :phoneNumber") 20 | suspend fun deleteCallerInfo(phoneNumber: String) 21 | 22 | @Query("DELETE FROM caller_info WHERE fullPhoneNumber IN (:phoneNumbers)") 23 | suspend fun deleteMultipleCallerInfo(phoneNumbers: List) 24 | 25 | @Query("SELECT fullPhoneNumber FROM caller_info") 26 | suspend fun getAllPhoneNumbers(): List 27 | 28 | @Query("DELETE FROM caller_info") 29 | suspend fun clearAllCallerInfo() 30 | 31 | @Query("SELECT * FROM caller_info") 32 | suspend fun getAllCallerInfo(): List 33 | } 34 | -------------------------------------------------------------------------------- /store/themeStore.ts: -------------------------------------------------------------------------------- 1 | import { MMKV } from "react-native-mmkv"; 2 | import { create } from "zustand"; 3 | import { createJSONStorage, persist, StateStorage } from "zustand/middleware"; 4 | import createSelectors from "./selectors"; 5 | 6 | const storage = new MMKV({ id: "theme-storage" }); 7 | 8 | const zustandStorage: StateStorage = { 9 | setItem: (name: string, value: string) => { 10 | return storage.set(name, value); 11 | }, 12 | getItem: (name: string) => { 13 | const value = storage.getString(name); 14 | return value ?? null; 15 | }, 16 | removeItem: (name: string) => { 17 | return storage.delete(name); 18 | }, 19 | }; 20 | 21 | export type ThemeMode = "light" | "dark" | "system"; 22 | 23 | type State = { 24 | themeMode: ThemeMode; 25 | }; 26 | 27 | type Action = { 28 | setThemeMode: (mode: ThemeMode) => void; 29 | }; 30 | 31 | const initialState = { 32 | themeMode: "system" as ThemeMode, // Default to system theme 33 | }; 34 | 35 | const useThemeStoreBase = create()( 36 | persist( 37 | (set) => ({ 38 | ...initialState, 39 | setThemeMode: (mode: ThemeMode) => set({ themeMode: mode }), 40 | }), 41 | { 42 | name: "theme-storage", 43 | storage: createJSONStorage(() => zustandStorage), 44 | } 45 | ) 46 | ); 47 | 48 | const useThemeStore = createSelectors(useThemeStoreBase); 49 | 50 | export default useThemeStore; 51 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/ic_close_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /store/selectedContactStore.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from "@/lib/types"; 2 | import { create } from "zustand"; 3 | import createSelectors from "./selectors"; 4 | 5 | type State = { 6 | selectedContacts: Contact[]; 7 | selectionMode: boolean; 8 | }; 9 | 10 | type Action = { 11 | toggleSelectionMode: (toogle: boolean) => void; 12 | selectContact: (contact: Contact) => void; 13 | clearSelection: () => void; 14 | }; 15 | 16 | const initialState: State = { 17 | selectedContacts: [], 18 | selectionMode: false, 19 | }; 20 | 21 | const useSelectedContactStoreBase = create((set, get) => ({ 22 | ...initialState, 23 | toggleSelectionMode: (toogle) => set(() => ({ selectionMode: toogle })), 24 | selectContact: (contact) => 25 | set((state) => { 26 | if ( 27 | state.selectedContacts.some( 28 | (c) => c.fullPhoneNumber === contact.fullPhoneNumber 29 | ) 30 | ) { 31 | return { 32 | selectedContacts: state.selectedContacts.filter( 33 | (c) => c.fullPhoneNumber !== contact.fullPhoneNumber 34 | ), 35 | }; 36 | } 37 | return { selectedContacts: [...state.selectedContacts, contact] }; 38 | }), 39 | clearSelection: () => set({ selectedContacts: [] }), 40 | })); 41 | 42 | const useSelectedContactStore = createSelectors(useSelectedContactStoreBase); 43 | 44 | export default useSelectedContactStore; 45 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We take security seriously and strive to quickly address any vulnerabilities. Please see the table below for supported versions: 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | 2.x | :white_check_mark: | 10 | | 1.x | :x: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | If you discover a security vulnerability, please help us keep the community safe by following these steps: 15 | 16 | 1. **Do not open a public issue.** 17 | 2. Email the maintainer directly at with details of the vulnerability. 18 | 3. Include as much information as possible to help us reproduce and address the issue quickly (e.g., steps to reproduce, affected versions, impact, and potential fixes). 19 | 4. We will acknowledge your report within 48 hours and work with you to resolve the issue promptly. 20 | 21 | ## Disclosure Policy 22 | 23 | - We ask that you give us a reasonable amount of time to address the vulnerability before any public disclosure. 24 | - Once the issue is resolved, we will publish a security advisory and credit the reporter (unless anonymity is requested). 25 | 26 | ## Security Best Practices 27 | 28 | - Always use the latest version of the app for the latest security updates. 29 | - Do not share sensitive information in public forums or issues. 30 | 31 | Thank you for helping keep Alternate secure! 32 | -------------------------------------------------------------------------------- /android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def reactNativeGradlePlugin = new File( 3 | providers.exec { 4 | workingDir(rootDir) 5 | commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })") 6 | }.standardOutput.asText.get().trim() 7 | ).getParentFile().absolutePath 8 | includeBuild(reactNativeGradlePlugin) 9 | 10 | def expoPluginsPath = new File( 11 | providers.exec { 12 | workingDir(rootDir) 13 | commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })") 14 | }.standardOutput.asText.get().trim(), 15 | "../android/expo-gradle-plugin" 16 | ).absolutePath 17 | includeBuild(expoPluginsPath) 18 | } 19 | 20 | plugins { 21 | id("com.facebook.react.settings") 22 | id("expo-autolinking-settings") 23 | } 24 | 25 | extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> 26 | if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { 27 | ex.autolinkLibrariesFromCommand() 28 | } else { 29 | ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand) 30 | } 31 | } 32 | expoAutolinking.useExpoModules() 33 | 34 | rootProject.name = 'Alternate' 35 | 36 | expoAutolinking.useExpoVersionCatalog() 37 | 38 | include ':app' 39 | includeBuild(expoAutolinking.reactNativeGradlePlugin) -------------------------------------------------------------------------------- /hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import useThemeStore from "@/store/themeStore"; 2 | import { useMaterial3Theme } from "@pchmn/expo-material3-theme"; 3 | import { 4 | DarkTheme as NavigationDarkTheme, 5 | DefaultTheme as NavigationDefaultTheme, 6 | } from "@react-navigation/native"; 7 | import merge from "deepmerge"; 8 | import { useColorScheme } from "react-native"; 9 | import { 10 | adaptNavigationTheme, 11 | MD3DarkTheme, 12 | MD3LightTheme, 13 | } from "react-native-paper"; 14 | 15 | export function useTheme() { 16 | const themeMode = useThemeStore.use.themeMode(); 17 | const systemColorScheme = useColorScheme(); 18 | const { theme } = useMaterial3Theme({ fallbackSourceColor: "#663399" }); 19 | 20 | // Determine the effective color scheme 21 | const colorScheme = themeMode === "system" ? systemColorScheme : themeMode; 22 | 23 | const customDarkTheme = { ...MD3DarkTheme, colors: theme.dark }; 24 | const customLightTheme = { ...MD3LightTheme, colors: theme.light }; 25 | 26 | const { LightTheme, DarkTheme } = adaptNavigationTheme({ 27 | reactNavigationLight: NavigationDefaultTheme, 28 | reactNavigationDark: NavigationDarkTheme, 29 | }); 30 | 31 | const CombinedDefaultTheme = merge(LightTheme, customLightTheme); 32 | const CombinedDarkTheme = merge(DarkTheme, customDarkTheme); 33 | 34 | const paperTheme = 35 | colorScheme === "dark" ? CombinedDarkTheme : CombinedDefaultTheme; 36 | 37 | return paperTheme; 38 | } 39 | -------------------------------------------------------------------------------- /hooks/useContactSearch.ts: -------------------------------------------------------------------------------- 1 | import { preprocessContactsForSearch, searchContacts } from "@/lib/search-utils"; 2 | import { Contact } from "@/lib/types"; 3 | import { useEffect, useMemo, useState } from "react"; 4 | 5 | /** 6 | * Custom hook for efficient contact searching with memoization and debouncing 7 | */ 8 | export function useContactSearch(contacts: Contact[], query: string, delay: number = 300) { 9 | const [searchResults, setSearchResults] = useState([]); 10 | const [isSearching, setIsSearching] = useState(false); 11 | 12 | // Memoize processed contacts to avoid recomputation 13 | const searchableContacts = useMemo(() => { 14 | return preprocessContactsForSearch(contacts); 15 | }, [contacts]); 16 | 17 | // Debounce search query 18 | const [debouncedQuery, setDebouncedQuery] = useState(query); 19 | 20 | useEffect(() => { 21 | setIsSearching(true); 22 | const handler = setTimeout(() => { 23 | setDebouncedQuery(query); 24 | setIsSearching(false); 25 | }, delay); 26 | 27 | return () => { 28 | clearTimeout(handler); 29 | setIsSearching(false); 30 | }; 31 | }, [query, delay]); 32 | 33 | // Perform search when debounced query changes 34 | useEffect(() => { 35 | const results = searchContacts(searchableContacts, debouncedQuery); 36 | setSearchResults(results); 37 | }, [debouncedQuery, searchableContacts]); 38 | 39 | return { 40 | searchResults, 41 | isSearching, 42 | searchableContacts, // Expose for potential external use 43 | }; 44 | } -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/java/expo/modules/callerid/CallDetectScreeningService.kt: -------------------------------------------------------------------------------- 1 | package expo.modules.callerid 2 | 3 | import android.os.Build 4 | import android.telecom.Call 5 | import android.telecom.CallScreeningService 6 | import android.util.Log 7 | 8 | class CallDetectScreeningService : CallScreeningService() { 9 | override fun onScreenCall(details: Call.Details) { 10 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 11 | if (details.callDirection == Call.Details.DIRECTION_INCOMING || details.callDirection == Call.Details.DIRECTION_OUTGOING) { 12 | val response = CallResponse.Builder() 13 | response.setDisallowCall(false) 14 | response.setRejectCall(false) 15 | response.setSilenceCall(false) 16 | response.setSkipCallLog(false) 17 | response.setSkipNotification(false) 18 | 19 | val callType = if (details.callDirection == Call.Details.DIRECTION_INCOMING) { 20 | "Incoming" 21 | } else { 22 | "Outgoing" 23 | } 24 | 25 | Log.d( 26 | "CallDetectScreeningService", 27 | "$callType call detected: ${details.handle.schemeSpecificPart}" 28 | ) 29 | 30 | // Store the phone number for both incoming and outgoing calls 31 | CallReceiver.callServiceNumber = details.handle.schemeSpecificPart 32 | 33 | respondToCall(details, response.build()) 34 | } 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /components/navigation-bar.tsx: -------------------------------------------------------------------------------- 1 | import { getHeaderTitle } from "@react-navigation/elements"; 2 | import { type NativeStackHeaderProps } from "@react-navigation/native-stack"; 3 | import React, { memo, useCallback } from "react"; 4 | import { Animated, StyleProp, ViewStyle } from "react-native"; 5 | import { Appbar } from "react-native-paper"; 6 | 7 | type CustomNavigationBarProps = NativeStackHeaderProps & { 8 | actions?: { icon: string; onPress: () => void; disabled?: boolean }[]; 9 | mode?: "small" | "medium" | "large" | "center-aligned"; 10 | popToTop?: boolean; 11 | style?: Animated.WithAnimatedValue>; 12 | elevated?: boolean; 13 | }; 14 | 15 | const CustomNavigationBar: React.FC = ({ 16 | navigation, 17 | route, 18 | options, 19 | back, 20 | actions = [], 21 | mode = "large", 22 | popToTop = false, 23 | style, 24 | elevated = false, 25 | }) => { 26 | const title = getHeaderTitle(options, route.name); 27 | 28 | const handleGoBack = useCallback(() => { 29 | popToTop ? navigation.popToTop() : navigation.goBack(); 30 | }, [navigation, popToTop]); 31 | 32 | return ( 33 | 34 | {back && } 35 | 36 | {actions.map((action, idx) => ( 37 | 43 | ))} 44 | 45 | ); 46 | }; 47 | 48 | export default memo(CustomNavigationBar); 49 | -------------------------------------------------------------------------------- /components/material3-photopicker-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useTheme } from "react-native-paper"; 3 | import Svg, { G, Path } from "react-native-svg"; 4 | 5 | const Material3PhotoPickerPlaceholder = () => { 6 | const theme = useTheme(); 7 | 8 | return ( 9 | 10 | 14 | 15 | 16 | 20 | 21 | 22 | 23 | ); 24 | }; 25 | 26 | export default Material3PhotoPickerPlaceholder; 27 | -------------------------------------------------------------------------------- /lib/types.ts: -------------------------------------------------------------------------------- 1 | import { Control, FieldPath, FieldValues } from "react-hook-form"; 2 | import { TextInputProps } from "react-native-paper"; 3 | 4 | export type Contact = { 5 | fullPhoneNumber: string; 6 | phoneNumber: string; 7 | countryCode: string; 8 | name: string; 9 | appointment: string; 10 | location: string; 11 | iosRow?: string; 12 | suffix?: string; 13 | prefix?: string; 14 | email?: string; 15 | notes?: string; 16 | website?: string; 17 | birthday?: string; 18 | labels?: string; 19 | nickname?: string; 20 | photo?: string; // Base64 encoded image string (data:image/jpeg;base64,...) 21 | }; 22 | 23 | export type ContactFormData = { 24 | name: string; 25 | phoneNumber: PhoneNumberData; 26 | appointment?: string; 27 | location?: string; 28 | suffix?: string; 29 | prefix?: string; 30 | email?: string; 31 | notes?: string; 32 | website?: string; 33 | birthday?: string; 34 | labels?: string; 35 | nickname?: string; 36 | photo?: string; // Base64 encoded image string (data:image/jpeg;base64,...) 37 | }; 38 | export type ListItem = 39 | | { type: "header"; letter: string } 40 | | { 41 | type: "item"; 42 | contact: Contact; 43 | index: number; 44 | isFirst: boolean; 45 | isLast: boolean; 46 | }; 47 | 48 | export type Country = { 49 | name: string; 50 | code: string; 51 | dialCode: string; 52 | flag: string; 53 | }; 54 | 55 | export type PhoneNumberData = { 56 | number: string; 57 | countryCode: string; 58 | dialCode: string; 59 | }; 60 | 61 | export interface PhoneNumberInputProps< 62 | TFieldValues extends FieldValues = FieldValues, 63 | TName extends FieldPath = FieldPath 64 | > extends Omit { 65 | control: Control; 66 | name: TName; 67 | rules?: any; 68 | } 69 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 36 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "alternate", 3 | "main": "expo-router/entry", 4 | "version": "2.4.5", 5 | "scripts": { 6 | "start": "expo start", 7 | "reset-project": "node ./scripts/reset-project.js", 8 | "android": "expo run:android", 9 | "ios": "expo run:ios", 10 | "web": "expo start --web", 11 | "lint": "expo lint", 12 | "build-fdroid": "npm ci && npx expo prebuild --platform android --clean && cd android && ./gradlew clean && ./gradlew assembleRelease" 13 | }, 14 | "dependencies": { 15 | "@expo/vector-icons": "^14.1.0", 16 | "@pchmn/expo-material3-theme": "^1.3.2", 17 | "@react-navigation/elements": "^2.3.8", 18 | "@react-navigation/native": "^7.1.6", 19 | "@shopify/flash-list": "^1.7.6", 20 | "expo": "53.0.19", 21 | "expo-clipboard": "~7.1.5", 22 | "expo-dev-client": "~5.2.4", 23 | "expo-document-picker": "^13.1.6", 24 | "expo-file-system": "^18.1.10", 25 | "expo-image-picker": "^16.1.4", 26 | "expo-router": "~5.1.3", 27 | "expo-sharing": "^13.1.5", 28 | "expo-splash-screen": "~0.30.10", 29 | "expo-status-bar": "~2.2.3", 30 | "libphonenumber-js": "^1.12.23", 31 | "react": "19.0.0", 32 | "react-hook-form": "^7.56.4", 33 | "react-native": "0.79.5", 34 | "react-native-actions-sheet": "^0.9.7", 35 | "react-native-date-picker": "^5.0.13", 36 | "react-native-gesture-handler": "~2.24.0", 37 | "react-native-mmkv": "^3.2.0", 38 | "react-native-paper": "^5.14.5", 39 | "react-native-reanimated": "~3.17.4", 40 | "react-native-safe-area-context": "5.4.0", 41 | "react-native-screens": "^4.13.1", 42 | "react-native-svg": "^15.12.0", 43 | "tslib": "^2.8.1", 44 | "zustand": "^5.0.5" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "^7.25.2", 48 | "@biomejs/biome": "^2.2.5", 49 | "@types/react": "~19.0.10", 50 | "typescript": "~5.8.3" 51 | }, 52 | "private": true 53 | } 54 | -------------------------------------------------------------------------------- /components/error-state.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { StyleSheet, View } from "react-native"; 3 | import { Button, Text } from "react-native-paper"; 4 | 5 | interface ErrorStateProps { 6 | error: string | null; 7 | onRetry: () => void; 8 | permissionGranted: boolean; 9 | onRequestPermissions: () => void; 10 | } 11 | 12 | export const ErrorState: React.FC = ({ 13 | error, 14 | onRetry, 15 | permissionGranted, 16 | onRequestPermissions, 17 | }) => { 18 | if (!permissionGranted) { 19 | return ( 20 | 21 | Permission Required 22 | 23 | This app needs permission to access contacts and phone state. 24 | 25 | 34 | 35 | ); 36 | } 37 | 38 | if (error) { 39 | return ( 40 | 41 | {error} 42 | 51 | 52 | ); 53 | } 54 | 55 | return null; 56 | }; 57 | 58 | const styles = StyleSheet.create({ 59 | centerContent: { 60 | flex: 1, 61 | padding: 16, 62 | justifyContent: "center", 63 | alignItems: "center", 64 | gap: 16, 65 | }, 66 | errorContainer: { 67 | flex: 1, 68 | justifyContent: "center", 69 | alignItems: "center", 70 | margin: 16, 71 | padding: 16, 72 | }, 73 | }); 74 | -------------------------------------------------------------------------------- /android/app/src/main/res/drawable/rn_edit_text_material.xml: -------------------------------------------------------------------------------- 1 | 2 | 16 | 22 | 23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /modules/caller-id/ios/CallerIdModule.swift: -------------------------------------------------------------------------------- 1 | import ExpoModulesCore 2 | 3 | public class CallerIdModule: 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('CallerId')` in JavaScript. 11 | Name("CallerId") 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(CallerIdView.self) { 38 | // Defines a setter for the `url` prop. 39 | Prop("url") { (view: CallerIdView, url: URL) in 40 | if view.webView.url != url { 41 | view.webView.load(URLRequest(url: url)) 42 | } 43 | } 44 | 45 | Events("onLoad") 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "Alternate", 4 | "slug": "alternate", 5 | "version": "2.4.6", 6 | "orientation": "portrait", 7 | "scheme": "alternate", 8 | "userInterfaceStyle": "automatic", 9 | "newArchEnabled": true, 10 | "icon": "./assets/icon/adaptive-icon.png", 11 | "ios": { 12 | "supportsTablet": true, 13 | "icon": { 14 | "dark": "./assets/icon/ios-dark.png", 15 | "light": "./assets/icon/ios-light.png", 16 | "tinted": "./assets/icon/ios-tinted.png" 17 | }, 18 | "runtimeVersion": { 19 | "policy": "appVersion" 20 | } 21 | }, 22 | "android": { 23 | "versionCode": 14, 24 | "adaptiveIcon": { 25 | "foregroundImage": "./assets/icon/adaptive-icon.png", 26 | "monochromeImage": "./assets/icon/adaptive-icon.png", 27 | "backgroundColor": "#ffffff" 28 | }, 29 | "icon": "./assets/icon/adaptive-icon.png", 30 | "edgeToEdgeEnabled": true, 31 | "package": "com.lulu786.Alternate", 32 | "runtimeVersion": "1.0.0" 33 | }, 34 | "web": { 35 | "bundler": "metro", 36 | "output": "static", 37 | "favicon": "./assets/icon/favicon.png" 38 | }, 39 | "plugins": [ 40 | "expo-router", 41 | [ 42 | "expo-image-picker", 43 | { 44 | "photosPermission": "The app accesses your photos to let you select contact profile pictures.", 45 | "cameraPermission": "The app accesses your camera to let you take contact profile pictures." 46 | } 47 | ], 48 | [ 49 | "expo-splash-screen", 50 | { 51 | "image": "./assets/icon/splash-icon-dark.png", 52 | "imageWidth": 200, 53 | "resizeMode": "contain", 54 | "backgroundColor": "#ffffff", 55 | "dark": { 56 | "image": "./assets/icon/splash-icon-light.png", 57 | "backgroundColor": "#000000" 58 | } 59 | } 60 | ] 61 | ], 62 | "experiments": { 63 | "typedRoutes": true 64 | }, 65 | "extra": { 66 | "router": {}, 67 | "eas": { 68 | "projectId": "0f06f9c0-1e64-4c3a-aa39-4724ca395d4f" 69 | } 70 | }, 71 | "owner": "lulu786", 72 | "updates": { 73 | "url": "https://u.expo.dev/0f06f9c0-1e64-4c3a-aa39-4724ca395d4f" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component, ErrorInfo, ReactNode } from "react"; 2 | import { StyleSheet, Text, TouchableOpacity, View } from "react-native"; 3 | 4 | interface Props { 5 | children: ReactNode; 6 | } 7 | 8 | interface State { 9 | hasError: boolean; 10 | error: Error | null; 11 | } 12 | 13 | class ErrorBoundary extends Component { 14 | public state: State = { 15 | hasError: false, 16 | error: null, 17 | }; 18 | 19 | public static getDerivedStateFromError(error: Error): State { 20 | return { hasError: true, error }; 21 | } 22 | 23 | public componentDidCatch(error: Error, errorInfo: ErrorInfo) { 24 | console.error("Uncaught error:", error, errorInfo); 25 | } 26 | 27 | private handleReset = () => { 28 | this.setState({ hasError: false, error: null }); 29 | }; 30 | 31 | public render() { 32 | if (this.state.hasError) { 33 | return ( 34 | 35 | Something went wrong 36 | {this.state.error?.message} 37 | 38 | Try again 39 | 40 | 41 | ); 42 | } 43 | 44 | return this.props.children; 45 | } 46 | } 47 | 48 | const styles = StyleSheet.create({ 49 | container: { 50 | flex: 1, 51 | justifyContent: "center", 52 | alignItems: "center", 53 | padding: 20, 54 | backgroundColor: "#fff", 55 | }, 56 | title: { 57 | fontSize: 24, 58 | fontWeight: "bold", 59 | marginBottom: 10, 60 | color: "#333", 61 | }, 62 | message: { 63 | fontSize: 16, 64 | textAlign: "center", 65 | marginBottom: 20, 66 | color: "#666", 67 | }, 68 | button: { 69 | backgroundColor: "#007AFF", 70 | paddingHorizontal: 20, 71 | paddingVertical: 10, 72 | borderRadius: 8, 73 | }, 74 | buttonText: { 75 | color: "#fff", 76 | fontSize: 16, 77 | fontWeight: "600", 78 | }, 79 | }); 80 | 81 | export default ErrorBoundary; 82 | -------------------------------------------------------------------------------- /modules/caller-id/android/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.library' 2 | apply plugin: 'com.google.devtools.ksp' 3 | 4 | group = 'expo.modules.callerid' 5 | version = '1.0.0' 6 | 7 | def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 8 | apply from: expoModulesCorePlugin 9 | applyKotlinExpoModulesCorePlugin() 10 | useCoreDependencies() 11 | useExpoPublishing() 12 | 13 | // If you want to use the managed Android SDK versions from expo-modules-core, set this to true. 14 | // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code. 15 | // Most of the time, you may like to manage the Android SDK versions yourself. 16 | def useManagedAndroidSdkVersions = false 17 | if (useManagedAndroidSdkVersions) { 18 | useDefaultAndroidSdkVersions() 19 | } else { 20 | buildscript { 21 | // Simple helper that allows the root project to override versions declared by this library. 22 | ext.safeExtGet = { prop, fallback -> 23 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 24 | } 25 | } 26 | project.android { 27 | compileSdkVersion safeExtGet("compileSdkVersion", 34) 28 | defaultConfig { 29 | minSdkVersion safeExtGet("minSdkVersion", 21) 30 | targetSdkVersion safeExtGet("targetSdkVersion", 34) 31 | } 32 | } 33 | } 34 | 35 | android { 36 | namespace = "expo.modules.callerid" 37 | 38 | defaultConfig { 39 | versionCode 2 40 | versionName "1.0.0" 41 | } 42 | 43 | lintOptions { 44 | abortOnError = false 45 | } 46 | } 47 | 48 | dependencies { 49 | def room_version = "2.7.1" 50 | // Room dependencies - using KSP for Kotlin classes 51 | implementation "androidx.room:room-runtime:$room_version" 52 | ksp "androidx.room:room-compiler:$room_version" 53 | implementation "androidx.room:room-ktx:$room_version" 54 | 55 | // Other dependencies 56 | implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.9.0' 57 | implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.9.0' 58 | implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2' 59 | } 60 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CountryCode, 3 | formatIncompletePhoneNumber, 4 | } from "libphonenumber-js"; 5 | import { getCountryByCode } from "./countries"; 6 | import type { Contact } from "./types"; 7 | 8 | export function trimDialCode(phoneNumber: string, countryCode: string): string { 9 | // Get the country by code 10 | const country = getCountryByCode(countryCode); 11 | 12 | if (!country) { 13 | return phoneNumber; // If no country found, return original number 14 | } 15 | 16 | // Remove the dial code from the phone number 17 | const dialCode = country.dialCode; 18 | 19 | // Check if the phone number starts with the dial code 20 | if (phoneNumber.startsWith(dialCode)) { 21 | return phoneNumber.slice(dialCode.length).trim(); // Return number without dial code 22 | } 23 | 24 | return phoneNumber; // Return original number if it doesn't start with dial code 25 | } 26 | 27 | export function getFormattedPhoneNumber(contact: Contact): string { 28 | try { 29 | return formatIncompletePhoneNumber( 30 | contact.fullPhoneNumber, 31 | contact.countryCode as CountryCode, 32 | ); 33 | } catch { 34 | return contact.fullPhoneNumber; 35 | } 36 | } 37 | 38 | export function getFormattedName(contact: Contact): string { 39 | let formattedName = contact.name.trim(); 40 | 41 | if (contact.prefix?.trim()) { 42 | formattedName = `${contact.prefix.trim()} ${formattedName}`; 43 | } 44 | 45 | if (contact.suffix?.trim()) { 46 | formattedName = `${formattedName}, ${contact.suffix.trim()}`; 47 | } 48 | 49 | return formattedName; 50 | } 51 | 52 | export function getFormattedDate(date: string): string { 53 | let parsedDate; 54 | if (!date) parsedDate = new Date(); 55 | else parsedDate = new Date(date); 56 | 57 | return parsedDate.toLocaleDateString("en-US", { 58 | year: "numeric", 59 | month: "long", 60 | day: "numeric", 61 | }); 62 | } 63 | 64 | export function getVisibleFields(contact: Contact | null): Set { 65 | if (!contact) return new Set(); 66 | 67 | const fields = [ 68 | "suffix", 69 | "prefix", 70 | "email", 71 | "notes", 72 | "website", 73 | "birthday", 74 | "nickname", 75 | ] as const; 76 | return new Set(fields.filter((field) => contact[field] !== "")); 77 | } 78 | -------------------------------------------------------------------------------- /android/app/src/main/java/com/lulu786/Alternate/MainApplication.kt: -------------------------------------------------------------------------------- 1 | package com.lulu786.Alternate 2 | 3 | import android.app.Application 4 | import android.content.res.Configuration 5 | 6 | import com.facebook.react.PackageList 7 | import com.facebook.react.ReactApplication 8 | import com.facebook.react.ReactNativeHost 9 | import com.facebook.react.ReactPackage 10 | import com.facebook.react.ReactHost 11 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load 12 | import com.facebook.react.defaults.DefaultReactNativeHost 13 | import com.facebook.react.soloader.OpenSourceMergedSoMapping 14 | import com.facebook.soloader.SoLoader 15 | 16 | import expo.modules.ApplicationLifecycleDispatcher 17 | import expo.modules.ReactNativeHostWrapper 18 | 19 | class MainApplication : Application(), ReactApplication { 20 | 21 | override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( 22 | this, 23 | object : DefaultReactNativeHost(this) { 24 | override fun getPackages(): List { 25 | val packages = PackageList(this).packages 26 | // Packages that cannot be autolinked yet can be added manually here, for example: 27 | // packages.add(MyReactNativePackage()) 28 | return packages 29 | } 30 | 31 | override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" 32 | 33 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG 34 | 35 | override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED 36 | override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED 37 | } 38 | ) 39 | 40 | override val reactHost: ReactHost 41 | get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) 42 | 43 | override fun onCreate() { 44 | super.onCreate() 45 | SoLoader.init(this, OpenSourceMergedSoMapping) 46 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { 47 | // If you opted-in for the New Architecture, we load the native entry point for this app. 48 | load() 49 | } 50 | ApplicationLifecycleDispatcher.onApplicationCreate(this) 51 | } 52 | 53 | override fun onConfigurationChanged(newConfig: Configuration) { 54 | super.onConfigurationChanged(newConfig) 55 | ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Alternate 2 | 3 | Thank you for your interest in contributing to Alternate! Your help is greatly appreciated. Please follow the guidelines below to ensure a smooth contribution process. 4 | 5 | ## How to Contribute 6 | 7 | 1. **Fork the Repository** 8 | - Click the "Fork" button at the top right of this page to create your own copy of the repository. 9 | 10 | 2. **Clone Your Fork** 11 | - Clone your forked repository to your local machine: 12 | 13 | ```bash 14 | git clone https://github.com/BioHazard786/Alternate.git 15 | cd Alternate 16 | ``` 17 | 18 | 3. **Create a Feature Branch** 19 | - Create a new branch for your feature or bugfix: 20 | 21 | ```bash 22 | git checkout -b feature/your-feature-name 23 | ``` 24 | 25 | 4. **Make Your Changes** 26 | - Write clear, concise code and add comments where necessary. 27 | - Follow the existing code style and structure. 28 | - Add or update tests as needed. 29 | 30 | 5. **Commit Your Changes** 31 | - Use descriptive commit messages: 32 | 33 | ```bash 34 | git commit -m "Add feature: description of your feature" 35 | ``` 36 | 37 | 6. **Push to Your Fork** 38 | - Push your branch to your forked repository: 39 | 40 | ```bash 41 | git push origin feature/your-feature-name 42 | ``` 43 | 44 | 7. **Open a Pull Request** 45 | - Go to the original repository and open a Pull Request from your branch. 46 | - Provide a clear description of your changes and reference any related issues. 47 | 48 | ## Code Style 49 | 50 | - Use consistent formatting and indentation. 51 | - Write clear and descriptive variable and function names. 52 | - Add comments for complex logic. 53 | - Use TypeScript for all new code. 54 | 55 | ## Commit Messages 56 | 57 | - Use the present tense ("Add feature" not "Added feature"). 58 | - Be concise but descriptive. 59 | - Reference issues where applicable (e.g., `Fixes #123`). 60 | 61 | ## Reporting Issues 62 | 63 | If you find a bug or have a feature request, please open an issue with: 64 | 65 | - A clear and descriptive title 66 | - Steps to reproduce (for bugs) 67 | - Expected and actual behavior 68 | - Screenshots or logs if applicable 69 | 70 | ## Code of Conduct 71 | 72 | Please be respectful and considerate in all interactions. See the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for details. 73 | 74 | ## Questions? 75 | 76 | If you have any questions, feel free to open an issue or contact the maintainer: 77 | 78 | - Mohd Zaid – [Telegram](https://t.me/LuLu786) – 79 | 80 | Thank you for helping make Alternate better! 81 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/java/expo/modules/callerid/database/CallerRepository.kt: -------------------------------------------------------------------------------- 1 | package expo.modules.callerid.database 2 | 3 | import android.content.Context 4 | import kotlinx.coroutines.runBlocking 5 | 6 | class CallerRepository(context: Context) { 7 | private val callerDao = CallerDatabase.getDatabase(context).callerDao() 8 | 9 | suspend fun getCallerInfo(phoneNumber: String): CallerEntity? { 10 | return callerDao.getCallerInfo(phoneNumber) 11 | } 12 | 13 | // Synchronous wrapper for CallReceiver usage 14 | fun getCallerInfoSync(phoneNumber: String): CallerEntity? { 15 | return try { 16 | runBlocking { 17 | callerDao.getCallerInfo(phoneNumber) 18 | } 19 | } catch (e: Exception) { 20 | null 21 | } 22 | } 23 | 24 | suspend fun storeCallerInfo(callerEntity: CallerEntity): Boolean { 25 | return try { 26 | callerDao.insertCallerInfo(callerEntity) 27 | true 28 | } catch (e: Exception) { 29 | false 30 | } 31 | } 32 | 33 | suspend fun storeMultipleCallerInfo(callerEntities: List): Boolean { 34 | return try { 35 | callerDao.insertMultipleCallerInfo(callerEntities) 36 | true 37 | } catch (e: Exception) { 38 | false 39 | } 40 | } 41 | 42 | suspend fun removeCallerInfo(fullPhoneNumber: String): Boolean { 43 | return try { 44 | callerDao.deleteCallerInfo(fullPhoneNumber) 45 | true 46 | } catch (e: Exception) { 47 | false 48 | } 49 | } 50 | 51 | suspend fun removeMultipleCallerInfo(fullPhoneNumbers: List): Boolean { 52 | return try { 53 | callerDao.deleteMultipleCallerInfo(fullPhoneNumbers) 54 | true 55 | } catch (e: Exception) { 56 | false 57 | } 58 | } 59 | 60 | suspend fun getAllStoredNumbers(): List { 61 | return try { 62 | callerDao.getAllPhoneNumbers() 63 | } catch (e: Exception) { 64 | emptyList() 65 | } 66 | } 67 | 68 | suspend fun getAllCallerInfo(): List { 69 | return try { 70 | callerDao.getAllCallerInfo() 71 | } catch (e: Exception) { 72 | emptyList() 73 | } 74 | } 75 | 76 | suspend fun clearAllCallerInfo(): Boolean { 77 | return try { 78 | callerDao.clearAllCallerInfo() 79 | true 80 | } catch (e: Exception) { 81 | false 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /android/gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m 13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m 14 | 15 | # When configured, Gradle will run in incubating parallel mode. 16 | # This option should only be used with decoupled projects. More details, visit 17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 18 | # org.gradle.parallel=true 19 | 20 | # AndroidX package structure to make it clearer which packages are bundled with the 21 | # Android operating system, and which are packaged with your app's APK 22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 23 | android.useAndroidX=true 24 | 25 | # Enable AAPT2 PNG crunching 26 | android.enablePngCrunchInReleaseBuilds=true 27 | 28 | # Use this property to specify which architecture you want to build. 29 | # You can also override it from the CLI using 30 | # ./gradlew -PreactNativeArchitectures=x86_64 31 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 32 | 33 | # Use this property to enable support to the new architecture. 34 | # This will allow you to use TurboModules and the Fabric render in 35 | # your application. You should enable this flag either if you want 36 | # to write custom TurboModules/Fabric components OR use libraries that 37 | # are providing them. 38 | newArchEnabled=true 39 | 40 | # Use this property to enable or disable the Hermes JS engine. 41 | # If set to false, you will be using JSC instead. 42 | hermesEnabled=true 43 | 44 | # Enable GIF support in React Native images (~200 B increase) 45 | expo.gif.enabled=true 46 | # Enable webp support in React Native images (~85 KB increase) 47 | expo.webp.enabled=true 48 | # Enable animated webp support (~3.4 MB increase) 49 | # Disabled by default because iOS doesn't support animated webp 50 | expo.webp.animated=false 51 | 52 | # Enable network inspector 53 | EX_DEV_CLIENT_NETWORK_INSPECTOR=true 54 | 55 | # Use legacy packaging to compress native libraries in the resulting APK. 56 | expo.useLegacyPackaging=false 57 | 58 | # Whether the app is configured to use edge-to-edge via the app config or `react-native-edge-to-edge` plugin 59 | expo.edgeToEdgeEnabled=true -------------------------------------------------------------------------------- /android/app/src/main/java/com/lulu786/Alternate/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.lulu786.Alternate 2 | import expo.modules.splashscreen.SplashScreenManager 3 | 4 | import android.os.Build 5 | import android.os.Bundle 6 | 7 | import com.facebook.react.ReactActivity 8 | import com.facebook.react.ReactActivityDelegate 9 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled 10 | import com.facebook.react.defaults.DefaultReactActivityDelegate 11 | 12 | import expo.modules.ReactActivityDelegateWrapper 13 | 14 | class MainActivity : ReactActivity() { 15 | override fun onCreate(savedInstanceState: Bundle?) { 16 | // Set the theme to AppTheme BEFORE onCreate to support 17 | // coloring the background, status bar, and navigation bar. 18 | // This is required for expo-splash-screen. 19 | // setTheme(R.style.AppTheme); 20 | // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af 21 | SplashScreenManager.registerOnActivity(this) 22 | // @generated end expo-splashscreen 23 | super.onCreate(null) 24 | } 25 | 26 | /** 27 | * Returns the name of the main component registered from JavaScript. This is used to schedule 28 | * rendering of the component. 29 | */ 30 | override fun getMainComponentName(): String = "main" 31 | 32 | /** 33 | * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] 34 | * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] 35 | */ 36 | override fun createReactActivityDelegate(): ReactActivityDelegate { 37 | return ReactActivityDelegateWrapper( 38 | this, 39 | BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, 40 | object : DefaultReactActivityDelegate( 41 | this, 42 | mainComponentName, 43 | fabricEnabled 44 | ){}) 45 | } 46 | 47 | /** 48 | * Align the back button behavior with Android S 49 | * where moving root activities to background instead of finishing activities. 50 | * @see onBackPressed 51 | */ 52 | override fun invokeDefaultOnBackPressed() { 53 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { 54 | if (!moveTaskToBack(false)) { 55 | // For non-root activities, use the default implementation to finish them. 56 | super.invokeDefaultOnBackPressed() 57 | } 58 | return 59 | } 60 | 61 | // Use the default back button implementation on Android S 62 | // because it's doing more than [Activity.moveTaskToBack] in fact. 63 | super.invokeDefaultOnBackPressed() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /hooks/useAdvancedContactSearch.ts: -------------------------------------------------------------------------------- 1 | import { ContactSearchIndex } from "@/lib/search-index"; 2 | import { preprocessContactsForSearch, searchContacts } from "@/lib/search-utils"; 3 | import { Contact } from "@/lib/types"; 4 | import { useEffect, useMemo, useRef, useState } from "react"; 5 | 6 | /** 7 | * Advanced search hook that automatically chooses the best search strategy 8 | * based on the number of contacts 9 | */ 10 | export function useAdvancedContactSearch( 11 | contacts: Contact[], 12 | query: string, 13 | delay: number = 300 14 | ) { 15 | const [searchResults, setSearchResults] = useState([]); 16 | const [isSearching, setIsSearching] = useState(false); 17 | 18 | // Use search index for large contact lists (>1000 contacts) 19 | const useSearchIndex = contacts.length > 1000; 20 | 21 | // Create and maintain search index 22 | const searchIndexRef = useRef(null); 23 | 24 | // Memoize processed contacts for small lists 25 | const searchableContacts = useMemo(() => { 26 | if (useSearchIndex) return []; 27 | return preprocessContactsForSearch(contacts); 28 | }, [contacts, useSearchIndex]); 29 | 30 | // Update search index when contacts change (for large lists) 31 | useEffect(() => { 32 | if (useSearchIndex) { 33 | if (!searchIndexRef.current) { 34 | searchIndexRef.current = new ContactSearchIndex(contacts); 35 | } else { 36 | searchIndexRef.current.updateIndex(contacts); 37 | } 38 | } 39 | }, [contacts, useSearchIndex]); 40 | 41 | // Debounced search query 42 | const [debouncedQuery, setDebouncedQuery] = useState(query); 43 | 44 | useEffect(() => { 45 | setIsSearching(true); 46 | const handler = setTimeout(() => { 47 | setDebouncedQuery(query); 48 | setIsSearching(false); 49 | }, delay); 50 | 51 | return () => { 52 | clearTimeout(handler); 53 | setIsSearching(false); 54 | }; 55 | }, [query, delay]); 56 | 57 | // Perform search when debounced query changes 58 | useEffect(() => { 59 | let results: Contact[] = []; 60 | 61 | if (debouncedQuery.trim()) { 62 | if (useSearchIndex && searchIndexRef.current) { 63 | // Use search index for large contact lists 64 | results = searchIndexRef.current.search(debouncedQuery, 50); 65 | } else { 66 | // Use regular search for smaller lists 67 | results = searchContacts(searchableContacts, debouncedQuery); 68 | } 69 | } 70 | 71 | setSearchResults(results); 72 | }, [debouncedQuery, searchableContacts, useSearchIndex]); 73 | 74 | return { 75 | searchResults, 76 | isSearching, 77 | searchStrategy: useSearchIndex ? 'index' : 'linear', 78 | contactCount: contacts.length, 79 | }; 80 | } -------------------------------------------------------------------------------- /lib/avatar-utils.ts: -------------------------------------------------------------------------------- 1 | import { colors } from "../constants/Colors"; 2 | import type { Contact } from "./types"; 3 | import { getFormattedName } from "./utils"; 4 | 5 | // Simple hash function for better randomization 6 | const simpleHash = (str: string): number => { 7 | let hash = 0; 8 | for (let i = 0; i < str.length; i++) { 9 | const char = str.charCodeAt(i); 10 | hash = (hash << 5) - hash + char; 11 | hash = hash & hash; // Convert to 32-bit integer 12 | } 13 | return Math.abs(hash); 14 | }; 15 | 16 | export const getAvatarColor = ( 17 | letter: string, 18 | isDark: boolean, 19 | index?: number, 20 | ): [string, string] => { 21 | const themeColors = isDark ? colors.dark : colors.light; 22 | const colorKeys = Object.keys(themeColors) as Array; 23 | 24 | let colorIndex: number; 25 | 26 | if (index !== undefined) { 27 | // Create a more randomized hash using both letter and index 28 | // This ensures better distribution across the color palette 29 | const combinedInput = `${letter.toLowerCase()}_${index}`; 30 | const hash = simpleHash(combinedInput); 31 | 32 | // Use a prime number for better distribution 33 | colorIndex = (hash * 31) % colorKeys.length; 34 | } else { 35 | // More randomized approach for single letter 36 | // Use multiple character transformations for better distribution 37 | const letterLower = letter.toLowerCase(); 38 | const letterUpper = letter.toUpperCase(); 39 | const combinedString = letterLower + letterUpper + letter; 40 | 41 | const hash = simpleHash(combinedString); 42 | colorIndex = (hash * 17) % colorKeys.length; // Use prime for better distribution 43 | } 44 | 45 | // Get color based on calculated index 46 | const selectedKey = colorKeys[colorIndex]; 47 | 48 | if (themeColors[selectedKey]) { 49 | return [themeColors[selectedKey].fg, themeColors[selectedKey].bg]; 50 | } 51 | 52 | // Fallback if something goes wrong 53 | const availableColors = Object.values(themeColors); 54 | const fallbackIndex = colorIndex % availableColors.length; 55 | return [availableColors[fallbackIndex].fg, availableColors[fallbackIndex].bg]; 56 | }; 57 | 58 | export const getSectionedContacts = (contacts: Contact[]) => { 59 | // Group contacts by first letter 60 | const grouped = contacts.reduce( 61 | (acc, contact) => { 62 | const fullName = getFormattedName(contact); 63 | const letter = fullName.charAt(0).toUpperCase(); 64 | if (!acc[letter]) { 65 | acc[letter] = []; 66 | } 67 | acc[letter].push(contact); 68 | return acc; 69 | }, 70 | {} as Record, 71 | ); 72 | 73 | // Convert to sectioned data format for flashlist 74 | const sections = Object.keys(grouped) 75 | .sort() 76 | .map((letter) => ({ 77 | title: letter, 78 | data: grouped[letter], 79 | })); 80 | 81 | return sections; 82 | }; 83 | -------------------------------------------------------------------------------- /app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import ErrorBoundary from "@/components/ErrorBoundary"; 2 | import CustomNavigationBar from "@/components/navigation-bar"; 3 | import { useTheme } from "@/hooks/useTheme"; 4 | import "@/services/sheets"; // Ensure sheets are registered 5 | import useContactStore from "@/store/contactStore"; 6 | import { ThemeProvider } from "@react-navigation/native"; 7 | import { SplashScreen, Stack } from "expo-router"; 8 | import { StatusBar } from "expo-status-bar"; 9 | import { useEffect, useState } from "react"; 10 | import { SheetProvider } from "react-native-actions-sheet"; 11 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 12 | import { PaperProvider } from "react-native-paper"; 13 | 14 | SplashScreen.preventAutoHideAsync(); 15 | 16 | export default function RootLayout() { 17 | const paperTheme = useTheme(); 18 | const fetchContacts = useContactStore.use.fetchContacts(); 19 | const [isReady, setIsReady] = useState(false); 20 | 21 | // Start fetching contacts during app initialization (splash screen time) 22 | useEffect(() => { 23 | const initializeApp = async () => { 24 | try { 25 | await fetchContacts(); 26 | } catch (error) { 27 | console.error("Error during app initialization:", error); 28 | } finally { 29 | setIsReady(true); 30 | await SplashScreen.hideAsync(); 31 | } 32 | }; 33 | 34 | initializeApp(); 35 | }, [fetchContacts]); 36 | 37 | if (!isReady) { 38 | return null; // Keep showing splash screen 39 | } 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | , 50 | }} 51 | > 52 | ( 57 | 58 | ), 59 | }} 60 | /> 61 | ( 66 | 71 | ), 72 | }} 73 | /> 74 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /constants/Colors.ts: -------------------------------------------------------------------------------- 1 | export const colors = { 2 | light: { 3 | // A-Z assigned to colors 4 | A: { bg: "#f0f0f0", fg: "#646464" }, // gray 5 | B: { bg: "#e6f4fe", fg: "#0d74ce" }, // blue 6 | C: { bg: "#def7f9", fg: "#107d98" }, // cyan 7 | D: { bg: "#381525", fg: "#ff92ad" }, // crimson (dark mode for contrast) 8 | E: { bg: "#e6f7ed", fg: "#208368" }, // jade (emerald) 9 | F: { bg: "#ffefd6", fg: "#cc4e00" }, // orange (fire) 10 | G: { bg: "#e6f6eb", fg: "#218358" }, // green 11 | H: { bg: "#eff1ef", fg: "#60655f" }, // olive (hint of gray) 12 | I: { bg: "#f0f1fe", fg: "#5753c6" }, // iris 13 | J: { bg: "#e6f7ed", fg: "#208368" }, // jade 14 | K: { bg: "#def7f9", fg: "#107d98" }, // cyan (close to key color) 15 | L: { bg: "#eef6d6", fg: "#5c7c2f" }, // lime 16 | M: { bg: "#ddf9f2", fg: "#027864" }, // mint 17 | N: { bg: "#edf2fe", fg: "#3a5bc7" }, // indigo (navy) 18 | O: { bg: "#ffefd6", fg: "#cc4e00" }, // orange 19 | P: { bg: "#fee9f5", fg: "#c2298a" }, // pink 20 | Q: { bg: "#f7edfe", fg: "#8145b5" }, // purple (queen) 21 | R: { bg: "#feebec", fg: "#ce2c31" }, // red 22 | S: { bg: "#e1f6fd", fg: "#00749e" }, // sky 23 | T: { bg: "#e0f8f3", fg: "#008573" }, // teal 24 | U: { bg: "#f7edfe", fg: "#8145b5" }, // purple (unique) 25 | V: { bg: "#f4f0fe", fg: "#6550b9" }, // violet 26 | W: { bg: "#fffab8", fg: "#9e6c00" }, // yellow (warm) 27 | X: { bg: "#fbebfb", fg: "#953ea3" }, // plum (exotic) 28 | Y: { bg: "#fffab8", fg: "#9e6c00" }, // yellow 29 | Z: { bg: "#f6edea", fg: "#7d5e54" }, // bronze (zest) 30 | }, 31 | dark: { 32 | // A-Z assigned to colors (dark theme) 33 | A: { bg: "#222222", fg: "#b4b4b4" }, // gray 34 | B: { bg: "#0d2847", fg: "#70b8ff" }, // blue 35 | C: { bg: "#082c36", fg: "#4ccce6" }, // cyan 36 | D: { bg: "#381525", fg: "#ff92ad" }, // crimson 37 | E: { bg: "#0f2e22", fg: "#1fd8a4" }, // jade (emerald) 38 | F: { bg: "#331e0b", fg: "#ffa057" }, // orange (fire) 39 | G: { bg: "#132d21", fg: "#3dd68c" }, // green 40 | H: { bg: "#212220", fg: "#afb5ad" }, // olive (hint of gray) 41 | I: { bg: "#202248", fg: "#b1a9ff" }, // iris 42 | J: { bg: "#0f2e22", fg: "#1fd8a4" }, // jade 43 | K: { bg: "#082c36", fg: "#4ccce6" }, // cyan (close to key color) 44 | L: { bg: "#1f2917", fg: "#bde56c" }, // lime 45 | M: { bg: "#092c2b", fg: "#58d5ba" }, // mint 46 | N: { bg: "#182449", fg: "#9eb1ff" }, // indigo (navy) 47 | O: { bg: "#331e0b", fg: "#ffa057" }, // orange 48 | P: { bg: "#37172f", fg: "#ff8dcc" }, // pink 49 | Q: { bg: "#301c3b", fg: "#d19dff" }, // purple (queen) 50 | R: { bg: "#3b1219", fg: "#ff9592" }, // red 51 | S: { bg: "#112840", fg: "#75c7f0" }, // sky 52 | T: { bg: "#0d2d2a", fg: "#0bd8b6" }, // teal 53 | U: { bg: "#301c3b", fg: "#d19dff" }, // purple (unique) 54 | V: { bg: "#291f43", fg: "#baa7ff" }, // violet 55 | W: { bg: "#2d2305", fg: "#f5e147" }, // yellow (warm) 56 | X: { bg: "#351a35", fg: "#e796f3" }, // plum (exotic) 57 | Y: { bg: "#2d2305", fg: "#f5e147" }, // yellow 58 | Z: { bg: "#262220", fg: "#d4b3a5" }, // bronze (zest) 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/drawable/ic_minimize_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 13 | 14 | 15 | 18 | 19 | 20 | 23 | -------------------------------------------------------------------------------- /android/gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /components/material3-avatar.tsx: -------------------------------------------------------------------------------- 1 | // import { Image } from "expo-image"; 2 | import { useId } from "react"; 3 | import type { ImageSourcePropType, StyleProp, ViewStyle } from "react-native"; 4 | import { useTheme } from "react-native-paper"; 5 | import Svg, { ClipPath, Defs, Image, Path, Text } from "react-native-svg"; 6 | 7 | interface Material3AvatarProps { 8 | letter?: string; 9 | backgroundColor?: string; 10 | textColor?: string; 11 | style?: StyleProp; 12 | photo?: string | ImageSourcePropType; 13 | size?: number; 14 | } 15 | 16 | const Material3Avatar = ({ 17 | letter, 18 | backgroundColor, 19 | textColor, 20 | style, 21 | photo, 22 | size = 200, 23 | }: Material3AvatarProps) => { 24 | const theme = useTheme(); 25 | const clipPathId = useId(); 26 | 27 | const pathData = `M85.812 11.542a22.48 22.48 0 0 1 28.376 0 22.48 22.48 0 0 0 17.754 4.758 22.48 22.48 0 0 1 24.574 14.188 22.48 22.48 0 0 0 12.998 12.998 22.48 22.48 0 0 1 14.188 24.574 22.48 22.48 0 0 0 4.758 17.754 22.48 22.48 0 0 1 0 28.376 22.48 22.48 0 0 0 -4.758 17.754 22.48 22.48 0 0 1 -14.188 24.574 22.48 22.48 0 0 0 -12.998 12.998 22.48 22.48 0 0 1 -24.574 14.188 22.48 22.48 0 0 0 -17.754 4.758 22.48 22.48 0 0 1 -28.376 0 22.48 22.48 0 0 0 -17.754 -4.758 22.48 22.48 0 0 1 -24.574 -14.188 22.48 22.48 0 0 0 -12.996 -12.998A22.48 22.48 0 0 1 16.3 131.944a22.48 22.48 0 0 0 -4.758 -17.754 22.48 22.48 0 0 1 0 -28.376A22.48 22.48 0 0 0 16.3 68.06a22.48 22.48 0 0 1 14.188 -24.574 22.48 22.48 0 0 0 12.998 -12.996A22.48 22.48 0 0 1 68.06 16.302a22.48 22.48 0 0 0 17.754 -4.758`; 28 | 29 | // Keep viewBox at original size (200) so path coordinates remain valid 30 | const viewBoxSize = 200; 31 | const viewBoxCenter = viewBoxSize / 2; 32 | const viewBoxFontSize = viewBoxSize * 0.5; 33 | 34 | if (photo) { 35 | return ( 36 | 42 | {/* 1. Define the clipping path */} 43 | 44 | 45 | {/* This path creates a rectangle with a diagonal slice at the top right */} 46 | 50 | 51 | 52 | 53 | {/* 2. Apply the clipping path to the image */} 54 | 61 | 62 | ); 63 | } 64 | 65 | return ( 66 | 72 | {/* The scalloped shape */} 73 | 77 | 78 | {/* The centered letter */} 79 | 88 | {letter} 89 | 90 | 91 | ); 92 | }; 93 | 94 | export default Material3Avatar; 95 | -------------------------------------------------------------------------------- /components/additional-field-sheet.tsx: -------------------------------------------------------------------------------- 1 | import { additionalFields } from "@/constants/AdditionalFields"; 2 | import { StyleSheet, View } from "react-native"; 3 | import ActionSheet, { 4 | ScrollView, 5 | SheetManager, 6 | SheetProps, 7 | } from "react-native-actions-sheet"; 8 | import { List, Text, useTheme } from "react-native-paper"; 9 | 10 | function AdditionalFieldSheet(props: SheetProps<"additional-field-sheet">) { 11 | const theme = useTheme(); 12 | const { visibleFields, setVisibleFields } = props.payload!; 13 | 14 | const toggleField = (fieldKey: string) => { 15 | const newVisibleFields = new Set(visibleFields); 16 | if (newVisibleFields.has(fieldKey)) { 17 | newVisibleFields.delete(fieldKey); 18 | } else { 19 | newVisibleFields.add(fieldKey); 20 | } 21 | setVisibleFields(newVisibleFields); 22 | SheetManager.hide("additional-field-sheet"); 23 | }; 24 | 25 | return ( 26 | 42 | 48 | 57 | 61 | Choose fields to add 62 | 63 | 64 | 65 | {additionalFields 66 | .filter((field) => !visibleFields.has(field.key)) 67 | .map((field, index) => ( 68 | } 72 | onPress={() => toggleField(field.key)} 73 | style={styles.list} 74 | /> 75 | ))} 76 | 77 | 78 | 79 | ); 80 | } 81 | 82 | const styles = StyleSheet.create({ 83 | container: { 84 | borderTopLeftRadius: 20, 85 | borderTopRightRadius: 20, 86 | }, 87 | bottomSheetContainer: { 88 | paddingVertical: 12, 89 | borderTopLeftRadius: 20, 90 | borderTopRightRadius: 20, 91 | height: "100%", 92 | }, 93 | bottomSheetContent: { 94 | flex: 1, 95 | paddingVertical: 16, 96 | }, 97 | header: { 98 | flexDirection: "row", 99 | justifyContent: "space-between", 100 | alignItems: "center", 101 | paddingVertical: 16, 102 | paddingHorizontal: 24, 103 | }, 104 | title: { 105 | fontWeight: "600", 106 | }, 107 | list: { 108 | paddingHorizontal: 16, 109 | }, 110 | }); 111 | 112 | export default AdditionalFieldSheet; 113 | -------------------------------------------------------------------------------- /ANDROID_RELEASE_SETUP.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Android Release Setup 2 | 3 | This document explains how to set up the GitHub Actions workflow for automated Android releases. 4 | 5 | ## Required GitHub Secrets 6 | 7 | You need to add the following secrets to your GitHub repository: 8 | 9 | ### 1. KEYSTORE_BASE64 10 | 11 | Your release keystore file encoded in base64. 12 | 13 | To generate this: 14 | 15 | ```bash 16 | # Navigate to your keystore directory 17 | cd keystore 18 | # Encode your keystore file to base64 19 | base64 -i alternate-release-key.jks | tr -d '\n' > keystore_base64.txt 20 | ``` 21 | 22 | Copy the contents of `keystore_base64.txt` and add it as `KEYSTORE_BASE64` secret in GitHub. 23 | 24 | ### 2. KEYSTORE_PASSWORD 25 | 26 | The password for your keystore file. 27 | 28 | ### 3. KEY_ALIAS 29 | 30 | The alias of your signing key within the keystore. 31 | 32 | ### 4. KEY_PASSWORD 33 | 34 | The password for your signing key. 35 | 36 | ## How to Add Secrets to GitHub 37 | 38 | 1. Go to your GitHub repository 39 | 2. Click on **Settings** tab 40 | 3. In the left sidebar, click **Secrets and variables** → **Actions** 41 | 4. Click **New repository secret** 42 | 5. Add each secret with the exact names mentioned above 43 | 44 | ## Workflow Trigger 45 | 46 | The workflow is triggered when you push a tag that starts with 'v': 47 | 48 | ```bash 49 | # Create and push a tag 50 | git tag v1.1.0 51 | git push origin v1.1.0 52 | ``` 53 | 54 | ## What the Workflow Does 55 | 56 | 1. **Setup Environment**: Installs Node.js and Java 57 | 2. **Install Dependencies**: Runs `npm ci` to install project dependencies 58 | 3. **Build APKs**: Uses Gradle to build release APKs for all architectures 59 | 4. **Sign APKs**: Signs the APKs with your release keystore 60 | 5. **Create Release**: Creates a GitHub release with the tag 61 | 6. **Upload APKs**: Uploads all split APKs to the release 62 | 63 | ## Generated APK Files 64 | 65 | The workflow creates the following APK files: 66 | 67 | - `alternate-arm64-v8a-[tag].apk` - For modern 64-bit ARM devices 68 | - `alternate-armeabi-v7a-[tag].apk` - For older 32-bit ARM devices 69 | - `alternate-x86_64-[tag].apk` - For 64-bit x86 devices 70 | - `alternate-x86-[tag].apk` - For 32-bit x86 devices 71 | - `output-metadata.json` - Build metadata 72 | 73 | ## Troubleshooting 74 | 75 | ### Common Issues: 76 | 77 | 1. **Keystore decoding fails**: Make sure your base64 encoding doesn't contain newlines 78 | 2. **Signing fails**: Verify your keystore password, key alias, and key password are correct 79 | 3. **Build fails**: Check that your dependencies are properly defined in package.json 80 | 81 | ### Testing the Build Locally 82 | 83 | Before pushing a tag, you can test the build process locally: 84 | 85 | ```bash 86 | # Install dependencies 87 | npm ci 88 | 89 | # Prebuild 90 | npx expo prebuild --platform android --clean 91 | 92 | # Build (replace with your actual keystore details) 93 | cd android 94 | ./gradlew assembleRelease \ 95 | -Pandroid.injected.signing.store.file=../keystore/alternate-release-key.jks \ 96 | -Pandroid.injected.signing.store.password=YOUR_KEYSTORE_PASSWORD \ 97 | -Pandroid.injected.signing.key.alias=YOUR_KEY_ALIAS \ 98 | -Pandroid.injected.signing.key.password=YOUR_KEY_PASSWORD 99 | ``` 100 | 101 | ## File Locations 102 | 103 | After a successful build, APKs will be located at: 104 | 105 | - `android/app/build/outputs/apk/release/app-arm64-v8a-release.apk` 106 | - `android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk` 107 | - `android/app/build/outputs/apk/release/app-x86_64-release.apk` 108 | - `android/app/build/outputs/apk/release/app-x86-release.apk` 109 | -------------------------------------------------------------------------------- /lib/search-utils.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from "./types"; 2 | 3 | // Pre-processed contact type for efficient searching 4 | export type SearchableContact = Contact & { 5 | searchableText: string; 6 | fullName: string; 7 | searchTokens: string[]; 8 | }; 9 | 10 | /** 11 | * Preprocesses contacts for efficient searching 12 | * This should be called whenever contacts change, not on every search 13 | */ 14 | export function preprocessContactsForSearch(contacts: Contact[]): SearchableContact[] { 15 | return contacts.map((contact): SearchableContact => { 16 | // Pre-build full name once 17 | const fullName = [contact.prefix, contact.name, contact.suffix] 18 | .filter(Boolean) 19 | .join(" "); 20 | 21 | // Pre-build searchable text with all relevant fields 22 | const searchableFields = [ 23 | fullName, 24 | contact.location || "", 25 | contact.appointment || "", 26 | contact.nickname || "", 27 | contact.email || "", 28 | `+${contact.fullPhoneNumber}`, 29 | contact.phoneNumber, 30 | ]; 31 | 32 | const searchableText = searchableFields.join(" ").toLowerCase(); 33 | 34 | // Tokenize for multi-word searches 35 | const searchTokens = searchableText 36 | .split(/\s+/) 37 | .filter(token => token.length > 0); 38 | 39 | return { 40 | ...contact, 41 | fullName, 42 | searchableText, 43 | searchTokens, 44 | }; 45 | }); 46 | } 47 | 48 | /** 49 | * Enhanced search function with multiple search strategies 50 | */ 51 | export function searchContacts( 52 | searchableContacts: SearchableContact[], 53 | query: string 54 | ): Contact[] { 55 | if (!query.trim()) return []; 56 | 57 | const lowerQuery = query.toLowerCase().trim(); 58 | const queryTokens = lowerQuery.split(/\s+/).filter(token => token.length > 0); 59 | 60 | // Strategy 1: Single token search - fastest for simple queries 61 | if (queryTokens.length === 1) { 62 | const token = queryTokens[0]; 63 | return searchableContacts.filter(contact => 64 | contact.searchableText.includes(token) 65 | ); 66 | } 67 | 68 | // Strategy 2: Multi-token search - for complex queries 69 | return searchableContacts.filter(contact => { 70 | // All query tokens must be found in the contact's searchable data 71 | return queryTokens.every(queryToken => 72 | contact.searchTokens.some(contactToken => 73 | contactToken.includes(queryToken) 74 | ) 75 | ); 76 | }); 77 | } 78 | 79 | /** 80 | * Fuzzy search for better user experience (optional enhancement) 81 | * This is more CPU intensive but provides better results for typos 82 | */ 83 | export function fuzzySearchContacts( 84 | searchableContacts: SearchableContact[], 85 | query: string, 86 | threshold: number = 0.6 87 | ): Contact[] { 88 | if (!query.trim()) return []; 89 | 90 | const lowerQuery = query.toLowerCase().trim(); 91 | 92 | return searchableContacts 93 | .map(contact => ({ 94 | contact, 95 | score: calculateFuzzyScore(contact.searchableText, lowerQuery) 96 | })) 97 | .filter(item => item.score >= threshold) 98 | .sort((a, b) => b.score - a.score) 99 | .map(item => item.contact); 100 | } 101 | 102 | /** 103 | * Simple fuzzy scoring algorithm 104 | */ 105 | function calculateFuzzyScore(text: string, query: string): number { 106 | if (text.includes(query)) return 1.0; 107 | 108 | let score = 0; 109 | let queryIndex = 0; 110 | 111 | for (let i = 0; i < text.length && queryIndex < query.length; i++) { 112 | if (text[i] === query[queryIndex]) { 113 | score++; 114 | queryIndex++; 115 | } 116 | } 117 | 118 | return queryIndex === query.length ? score / query.length : 0; 119 | } -------------------------------------------------------------------------------- /lib/search-index.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from "./types"; 2 | 3 | /** 4 | * Search index for ultra-fast contact searching 5 | * Useful when you have thousands of contacts 6 | */ 7 | export class ContactSearchIndex { 8 | private index: Map> = new Map(); 9 | private contacts: Map = new Map(); 10 | 11 | constructor(contacts: Contact[]) { 12 | this.buildIndex(contacts); 13 | } 14 | 15 | private buildIndex(contacts: Contact[]): void { 16 | this.index.clear(); 17 | this.contacts.clear(); 18 | 19 | for (const contact of contacts) { 20 | const contactId = contact.fullPhoneNumber; 21 | this.contacts.set(contactId, contact); 22 | 23 | // Build searchable terms 24 | const terms = this.extractSearchTerms(contact); 25 | 26 | for (const term of terms) { 27 | // Create n-grams for partial matching 28 | const nGrams = this.createNGrams(term.toLowerCase(), 2); 29 | 30 | for (const nGram of nGrams) { 31 | if (!this.index.has(nGram)) { 32 | this.index.set(nGram, new Set()); 33 | } 34 | this.index.get(nGram)!.add(contactId); 35 | } 36 | } 37 | } 38 | } 39 | 40 | private extractSearchTerms(contact: Contact): string[] { 41 | const terms: string[] = []; 42 | 43 | // Name components 44 | if (contact.prefix) terms.push(contact.prefix); 45 | terms.push(contact.name); 46 | if (contact.suffix) terms.push(contact.suffix); 47 | 48 | // Other fields 49 | if (contact.email) terms.push(contact.email); 50 | if (contact.nickname) terms.push(contact.nickname); 51 | if (contact.location) terms.push(contact.location); 52 | if (contact.appointment) terms.push(contact.appointment); 53 | 54 | // Phone numbers 55 | terms.push(contact.phoneNumber); 56 | terms.push(`+${contact.fullPhoneNumber}`); 57 | 58 | return terms.filter(term => term.length > 0); 59 | } 60 | 61 | private createNGrams(text: string, n: number): string[] { 62 | const nGrams: string[] = []; 63 | const cleanText = text.replace(/[^\w\d]/g, '').toLowerCase(); 64 | 65 | // Add full text 66 | nGrams.push(cleanText); 67 | 68 | // Add n-grams 69 | for (let i = 0; i <= cleanText.length - n; i++) { 70 | nGrams.push(cleanText.slice(i, i + n)); 71 | } 72 | 73 | return nGrams; 74 | } 75 | 76 | search(query: string, limit: number = 100): Contact[] { 77 | if (!query.trim()) return []; 78 | 79 | const lowerQuery = query.toLowerCase().replace(/[^\w\d\s]/g, ''); 80 | const queryTerms = lowerQuery.split(/\s+/).filter(term => term.length > 0); 81 | 82 | if (queryTerms.length === 0) return []; 83 | 84 | // Find contacts that match all query terms 85 | let candidateIds: Set | null = null; 86 | 87 | for (const term of queryTerms) { 88 | const termCandidates = new Set(); 89 | 90 | // Find all n-grams that contain this term 91 | for (const [nGram, contactIds] of this.index.entries()) { 92 | if (nGram.includes(term)) { 93 | for (const contactId of contactIds) { 94 | termCandidates.add(contactId); 95 | } 96 | } 97 | } 98 | 99 | if (candidateIds === null) { 100 | candidateIds = termCandidates; 101 | } else { 102 | // Intersection - only keep contacts that match all terms 103 | const intersection = new Set(); 104 | for (const id of candidateIds) { 105 | if (termCandidates.has(id)) { 106 | intersection.add(id); 107 | } 108 | } 109 | candidateIds = intersection; 110 | } 111 | 112 | // If no candidates left, no point continuing 113 | if (candidateIds.size === 0) break; 114 | } 115 | 116 | if (!candidateIds || candidateIds.size === 0) return []; 117 | 118 | // Convert contact IDs back to contacts and apply limit 119 | const results: Contact[] = []; 120 | let count = 0; 121 | 122 | for (const contactId of candidateIds) { 123 | if (count >= limit) break; 124 | const contact = this.contacts.get(contactId); 125 | if (contact) { 126 | results.push(contact); 127 | count++; 128 | } 129 | } 130 | 131 | return results; 132 | } 133 | 134 | /** 135 | * Update the index with new contacts 136 | */ 137 | updateIndex(contacts: Contact[]): void { 138 | this.buildIndex(contacts); 139 | } 140 | 141 | /** 142 | * Get index statistics for debugging 143 | */ 144 | getStats(): { indexSize: number; contactCount: number; averageTermsPerContact: number } { 145 | return { 146 | indexSize: this.index.size, 147 | contactCount: this.contacts.size, 148 | averageTermsPerContact: this.index.size / Math.max(this.contacts.size, 1) 149 | }; 150 | } 151 | } -------------------------------------------------------------------------------- /app/search.tsx: -------------------------------------------------------------------------------- 1 | import { ContactItem } from "@/components/contact-item"; 2 | import { EmptyContactsList } from "@/components/empty-contactsList"; 3 | import { SectionHeader } from "@/components/section-header"; 4 | import { useContactSearch } from "@/hooks/useContactSearch"; 5 | import { getSectionedContacts } from "@/lib/avatar-utils"; 6 | import { ListItem } from "@/lib/types"; 7 | import useContactStore from "@/store/contactStore"; 8 | import { NativeStackHeaderProps } from "@react-navigation/native-stack"; 9 | import { FlashList } from "@shopify/flash-list"; 10 | import { Stack } from "expo-router"; 11 | import { StatusBar } from "expo-status-bar"; 12 | import { useState } from "react"; 13 | import { Platform, StyleSheet, View } from "react-native"; 14 | import { Searchbar, useTheme } from "react-native-paper"; 15 | import { useSafeAreaInsets } from "react-native-safe-area-context"; 16 | 17 | export default function SearchScreen() { 18 | const theme = useTheme(); 19 | const insets = useSafeAreaInsets(); 20 | 21 | const [searchQuery, setSearchQuery] = useState(""); 22 | const contacts = useContactStore.use.contacts(); 23 | 24 | // Use the optimized search hook 25 | const { searchResults, isSearching } = useContactSearch(contacts, searchQuery, 300); 26 | 27 | // Create sectioned data for FlashList 28 | const sectionedData = getSectionedContacts(searchResults); 29 | 30 | // Flatten sectioned data into single array with headers and items 31 | const listData: ListItem[] = sectionedData.flatMap((section) => [ 32 | { type: "header" as const, letter: section.title }, 33 | ...section.data.map( 34 | (contact, index): ListItem => ({ 35 | type: "item" as const, 36 | contact, 37 | index: index, 38 | isFirst: index === 0, 39 | isLast: index === section.data.length - 1, 40 | }) 41 | ), 42 | ]); 43 | 44 | // Render function for FlashList 45 | const renderItem = ({ item }: { item: ListItem }) => { 46 | if (item.type === "header") { 47 | return ; 48 | } else { 49 | return ( 50 | 56 | ); 57 | } 58 | }; 59 | 60 | return ( 61 | 62 | {Platform.OS === "ios" && ( 63 | 68 | )} 69 | ( 74 | 80 | ), 81 | }} 82 | /> 83 | 84 | { 89 | if (item.type === "header") { 90 | return `header-${item.letter}`; 91 | } else { 92 | return `contact-${item.contact.fullPhoneNumber}`; 93 | } 94 | }} 95 | contentContainerStyle={styles.listContainer} 96 | ListEmptyComponent={ 97 | 98 | } 99 | /> 100 | 101 | 102 | ); 103 | } 104 | 105 | type CustomNavigationBarProps = NativeStackHeaderProps & { 106 | searchQuery: string; 107 | setSearchQuery: (query: string) => void; 108 | loading: boolean; 109 | }; 110 | 111 | function CustomSearchNavigationBar({ 112 | navigation, 113 | searchQuery, 114 | setSearchQuery, 115 | loading, 116 | }: CustomNavigationBarProps) { 117 | const insets = useSafeAreaInsets(); 118 | const theme = useTheme(); 119 | 120 | return ( 121 | <> 122 | 128 | 129 | setSearchQuery(query)} 132 | value={searchQuery} 133 | mode="view" 134 | autoFocus={true} 135 | icon="arrow-left" 136 | onIconPress={() => navigation.goBack()} 137 | loading={loading} 138 | /> 139 | 140 | 141 | ); 142 | } 143 | 144 | const styles = StyleSheet.create({ 145 | container: { 146 | flex: 1, 147 | }, 148 | content: { 149 | flex: 1, 150 | }, 151 | listContainer: { 152 | padding: 16, 153 | }, 154 | }); 155 | -------------------------------------------------------------------------------- /components/contact-item.tsx: -------------------------------------------------------------------------------- 1 | import type { Contact } from "@/lib/types"; 2 | import { getFormattedName, getFormattedPhoneNumber } from "@/lib/utils"; 3 | import useSelectedContactStore from "@/store/selectedContactStore"; 4 | import { router } from "expo-router"; 5 | import type React from "react"; 6 | import { Image, StyleSheet, Text, View } from "react-native"; 7 | import { Avatar, Icon, TouchableRipple, useTheme } from "react-native-paper"; 8 | import { getAvatarColor } from "../lib/avatar-utils"; 9 | 10 | interface ContactItemProps { 11 | contact: Contact; 12 | index: number; 13 | isFirst?: boolean; 14 | isLast?: boolean; 15 | } 16 | 17 | export const ContactItem: React.FC = ({ 18 | contact, 19 | index, 20 | isFirst = false, 21 | isLast = false, 22 | }) => { 23 | const theme = useTheme(); 24 | const fullName = getFormattedName(contact); 25 | const letter = fullName.charAt(0).toUpperCase(); 26 | 27 | const selectionMode = useSelectedContactStore.use.selectionMode(); 28 | const toogleSelectionMode = useSelectedContactStore.use.toggleSelectionMode(); 29 | const selectedContacts = useSelectedContactStore.use.selectedContacts(); 30 | const setSelectContact = useSelectedContactStore.use.selectContact(); 31 | const clearSelection = useSelectedContactStore.use.clearSelection(); 32 | const [avatarBackgroundColor, avatarTextColor] = getAvatarColor( 33 | letter, 34 | theme.dark, 35 | index, 36 | ); 37 | 38 | const isSelected = selectedContacts.some( 39 | (c) => c.fullPhoneNumber === contact.fullPhoneNumber, 40 | ); 41 | 42 | const handlePress = () => { 43 | if (selectionMode) { 44 | if (isSelected && selectedContacts.length === 1) { 45 | toogleSelectionMode(false); 46 | clearSelection(); 47 | } else { 48 | setSelectContact(contact); 49 | } 50 | } else { 51 | router.push({ 52 | pathname: "/preview-contact", 53 | params: { fullPhoneNumber: contact.fullPhoneNumber, index }, 54 | }); 55 | } 56 | }; 57 | 58 | const handleLongPress = () => { 59 | if (selectedContacts.length === 0) toogleSelectionMode(true); 60 | if (isSelected && selectedContacts.length === 1) { 61 | toogleSelectionMode(false); 62 | clearSelection(); 63 | } else { 64 | setSelectContact(contact); 65 | } 66 | }; 67 | 68 | return ( 69 | 85 | 86 | {isSelected ? ( 87 | 93 | 98 | 99 | ) : contact.photo ? ( 100 | 101 | ) : ( 102 | 108 | )} 109 | 110 | 120 | {fullName} 121 | 122 | 130 | +{getFormattedPhoneNumber(contact)} 131 | 132 | 133 | 134 | 135 | ); 136 | }; 137 | 138 | const styles = StyleSheet.create({ 139 | touchableContainer: { 140 | paddingHorizontal: 20, 141 | paddingVertical: 15, 142 | borderRadius: 5, 143 | }, 144 | container: { 145 | flexDirection: "row", 146 | alignItems: "center", 147 | }, 148 | firstItem: { 149 | borderTopLeftRadius: 16, 150 | borderTopRightRadius: 16, 151 | }, 152 | lastItem: { 153 | borderBottomLeftRadius: 16, 154 | borderBottomRightRadius: 16, 155 | marginBottom: 16, 156 | }, 157 | itemWithGap: { 158 | marginBottom: 2, 159 | }, 160 | textContainer: { 161 | flex: 1, 162 | marginLeft: 16, 163 | }, 164 | title: { 165 | fontSize: 18, 166 | }, 167 | description: { 168 | fontSize: 14, 169 | marginTop: 2, 170 | }, 171 | selectedContainer: { 172 | borderRadius: 50, 173 | width: 45, 174 | height: 45, 175 | justifyContent: "center", 176 | alignItems: "center", 177 | }, 178 | avatarImage: { 179 | width: 45, 180 | height: 45, 181 | borderRadius: 22.5, 182 | }, 183 | }); 184 | -------------------------------------------------------------------------------- /components/phone-number-input.tsx: -------------------------------------------------------------------------------- 1 | import { COUNTRIES } from "@/lib/countries"; 2 | import { Country, PhoneNumberData, PhoneNumberInputProps } from "@/lib/types"; 3 | import React, { useState } from "react"; 4 | import { Controller, FieldPath, FieldValues } from "react-hook-form"; 5 | 6 | import { StyleSheet, TouchableOpacity, View } from "react-native"; 7 | import { SheetManager } from "react-native-actions-sheet"; 8 | import { Text, TextInput, useTheme } from "react-native-paper"; 9 | 10 | const PhoneNumberInput = < 11 | TFieldValues extends FieldValues = FieldValues, 12 | TName extends FieldPath = FieldPath 13 | >({ 14 | control, 15 | name, 16 | rules, 17 | ...props 18 | }: PhoneNumberInputProps) => { 19 | const theme = useTheme(); 20 | 21 | // Selecting default country based on control's default values 22 | const defaultCountry = 23 | COUNTRIES.find( 24 | (country) => 25 | country.code === control._defaultValues?.phoneNumber?.countryCode 26 | ) || COUNTRIES[0]; 27 | 28 | // Internal state for selected country 29 | const [selectedCountry, setSelectedCountry] = 30 | useState(defaultCountry); 31 | 32 | const handlePhoneChange = ( 33 | phoneNumber: string, 34 | onChange: (...event: any[]) => void 35 | ) => { 36 | const phoneData: PhoneNumberData = { 37 | number: phoneNumber, 38 | countryCode: selectedCountry.code, 39 | dialCode: selectedCountry.dialCode, 40 | }; 41 | onChange(phoneData); 42 | }; 43 | 44 | return ( 45 | { 52 | if (!value || !value.number?.trim()) { 53 | return "Phone number is required"; 54 | } 55 | 56 | // Check if phone number contains only digits 57 | if (!/^\d+$/.test(value.number.trim())) { 58 | return "Phone number should contain only numbers"; 59 | } 60 | }, 61 | } 62 | } 63 | render={({ field: { onChange, onBlur, value } }) => { 64 | return ( 65 | 66 | 75 | SheetManager.show("country-selector-sheet", { 76 | payload: { 77 | selectedCountry, 78 | setSelectedCountry, 79 | onChange, 80 | currentValue: value, 81 | }, 82 | }) 83 | } 84 | activeOpacity={0.7} 85 | accessibilityLabel={`Selected country: ${selectedCountry.name}, dial code +${selectedCountry.dialCode}`} 86 | accessibilityHint="Tap to select a different country" 87 | accessibilityRole="button" 88 | > 89 | 90 | {selectedCountry.flag} 91 | 92 | 98 | ▼ 99 | 100 | 103 | +{selectedCountry.dialCode} 104 | 105 | 106 | handlePhoneChange(text, onChange)} 110 | onBlur={onBlur} 111 | mode="outlined" 112 | keyboardType="phone-pad" 113 | style={{ flex: 1 }} 114 | /> 115 | 116 | ); 117 | }} 118 | /> 119 | ); 120 | }; 121 | 122 | const styles = StyleSheet.create({ 123 | container: { 124 | flex: 1, 125 | gap: 12, 126 | flexDirection: "row", 127 | flexGrow: 1, 128 | alignItems: "flex-end", 129 | }, 130 | flagContainer: { 131 | borderWidth: 1, 132 | minWidth: 80, 133 | height: 50, 134 | flexDirection: "row", 135 | flex: 0, 136 | alignItems: "center", 137 | justifyContent: "center", 138 | flexShrink: 0, 139 | borderRadius: 4, 140 | paddingHorizontal: 8, 141 | gap: 4, 142 | }, 143 | flag: { 144 | fontSize: 18, 145 | }, 146 | dialCode: { 147 | fontSize: 14, 148 | fontWeight: "500", 149 | }, 150 | chevron: { 151 | fontSize: 8, 152 | opacity: 0.6, 153 | }, 154 | sheetContainer: { 155 | flex: 1, 156 | alignItems: "center", 157 | }, 158 | bottomSheetBackground: { 159 | borderTopLeftRadius: 20, 160 | borderTopRightRadius: 20, 161 | }, 162 | listContainer: { 163 | paddingBottom: 20, 164 | flexGrow: 1, 165 | }, 166 | emptyContainer: { 167 | flex: 1, 168 | alignItems: "center", 169 | justifyContent: "center", 170 | paddingVertical: 32, 171 | }, 172 | listFlagContainer: { 173 | width: 32, 174 | height: 32, 175 | borderRadius: 16, 176 | alignItems: "center", 177 | justifyContent: "center", 178 | marginRight: 8, 179 | }, 180 | }); 181 | 182 | export default PhoneNumberInput; 183 | -------------------------------------------------------------------------------- /modules/caller-id/android/src/main/res/layout/caller_info_dialog.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | 18 | 19 | 20 | 26 | 27 | 35 | 36 | 45 | 46 | 54 | 55 | 63 | 64 | 65 | 66 | 72 | 73 | 74 | 80 | 81 | 90 | 91 | 92 | 101 | 102 | 103 | 113 | 114 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | . 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | . Translations are available at 128 | . 129 | -------------------------------------------------------------------------------- /components/country-selector-sheet.tsx: -------------------------------------------------------------------------------- 1 | import useDebounce from "@/hooks/useDebounce"; 2 | import { COUNTRIES } from "@/lib/countries"; 3 | import { Country, PhoneNumberData } from "@/lib/types"; 4 | import { FlashList } from "@shopify/flash-list"; 5 | import { useEffect, useState } from "react"; 6 | import { StyleSheet, View } from "react-native"; 7 | import ActionSheet, { 8 | SheetManager, 9 | SheetProps, 10 | } from "react-native-actions-sheet"; 11 | import { List, Searchbar, Text, useTheme } from "react-native-paper"; 12 | 13 | function CountrySelectorSheet(props: SheetProps<"country-selector-sheet">) { 14 | const theme = useTheme(); 15 | const [searchResults, setSearchResults] = useState(COUNTRIES); 16 | const [searchQuery, setSearchQuery] = useState(""); 17 | const [debouncedSearchTerm, loading] = useDebounce(searchQuery, 300); 18 | const { selectedCountry, setSelectedCountry, onChange, currentValue } = 19 | props.payload!; 20 | 21 | const handleCountrySelect = ( 22 | country: Country, 23 | onChange: (...event: any[]) => void, 24 | currentValue: any 25 | ) => { 26 | setSelectedCountry(country); 27 | 28 | // Update the form value with new country data 29 | const phoneData: PhoneNumberData = { 30 | number: currentValue?.number || "", 31 | countryCode: country.code, 32 | dialCode: country.dialCode, 33 | }; 34 | onChange(phoneData); 35 | SheetManager.hide("country-selector-sheet"); 36 | }; 37 | 38 | const renderCountryItem = ({ item }: { item: Country }) => ( 39 | handleCountrySelect(item, onChange, currentValue)} 45 | style={{ 46 | backgroundColor: 47 | selectedCountry.code === item.code 48 | ? theme.colors.secondaryContainer 49 | : "transparent", 50 | paddingLeft: 16, 51 | }} 52 | left={() => ( 53 | 59 | 60 | {item.flag} 61 | 62 | 63 | )} 64 | right={() => 65 | selectedCountry.code === item.code ? ( 66 | 67 | ) : null 68 | } 69 | /> 70 | ); 71 | 72 | useEffect(() => { 73 | if (debouncedSearchTerm) { 74 | const lowerTerm = debouncedSearchTerm.toLowerCase(); 75 | const results = COUNTRIES.filter((country) => { 76 | const name = country.name.toLowerCase(); 77 | const code = country.code.toLowerCase(); 78 | const dialCode = country.dialCode.toString(); 79 | return ( 80 | name.includes(lowerTerm) || 81 | code.includes(lowerTerm) || 82 | dialCode.includes(lowerTerm) 83 | ); 84 | }); 85 | setSearchResults(results); 86 | } else { 87 | setSearchResults(COUNTRIES); 88 | } 89 | }, [debouncedSearchTerm]); 90 | 91 | return ( 92 | 107 | 113 | 114 | 126 | 127 | item.code} 132 | ListEmptyComponent={} 133 | contentContainerStyle={{ paddingBottom: 100 }} 134 | /> 135 | 136 | 137 | ); 138 | } 139 | 140 | function EmptyCountryList() { 141 | const theme = useTheme(); 142 | return ( 143 | 144 | 148 | No countries found 149 | 150 | 151 | ); 152 | } 153 | 154 | const styles = StyleSheet.create({ 155 | container: { 156 | borderTopLeftRadius: 20, 157 | borderTopRightRadius: 20, 158 | }, 159 | bottomSheetContainer: { 160 | paddingVertical: 12, 161 | borderTopLeftRadius: 20, 162 | borderTopRightRadius: 20, 163 | height: "100%", 164 | }, 165 | indicatorStyle: {}, 166 | listFlagContainer: { 167 | width: 32, 168 | height: 32, 169 | borderRadius: 16, 170 | alignItems: "center", 171 | justifyContent: "center", 172 | marginRight: 8, 173 | }, 174 | flag: { 175 | fontSize: 18, 176 | }, 177 | searchContainer: { 178 | paddingHorizontal: 16, 179 | paddingVertical: 0, 180 | paddingBottom: 20, 181 | }, 182 | emptyContainer: { 183 | flex: 1, 184 | justifyContent: "center", 185 | alignItems: "center", 186 | padding: 16, 187 | }, 188 | }); 189 | 190 | export default CountrySelectorSheet; 191 | -------------------------------------------------------------------------------- /SEARCH_OPTIMIZATION.md: -------------------------------------------------------------------------------- 1 | # Contact Search Performance Optimizations 2 | 3 | This document outlines the various performance optimizations implemented for the contact search functionality. 4 | 5 | ## Summary of Optimizations 6 | 7 | ### 1. **Pre-processing and Memoization** 8 | 9 | - **File**: `lib/search-utils.ts` 10 | - **Benefit**: Eliminates repeated string operations during search 11 | - **Improvement**: ~60-80% faster for typical search operations 12 | 13 | **Before**: String concatenation and lowercasing on every search 14 | 15 | ```typescript 16 | const fullName = [contact.prefix, contact.name, contact.suffix] 17 | .filter(Boolean) 18 | .join(" ") 19 | .toLowerCase(); 20 | // This ran for every contact on every search! 21 | ``` 22 | 23 | **After**: Pre-processed once, reused for all searches 24 | 25 | ```typescript 26 | const searchableContacts = useMemo(() => { 27 | return preprocessContactsForSearch(contacts); 28 | }, [contacts]); 29 | ``` 30 | 31 | ### 2. **Optimized Search Algorithm** 32 | 33 | - **File**: `lib/search-utils.ts` 34 | - **Benefit**: Faster filtering with single string contains vs multiple field checks 35 | - **Improvement**: ~40-50% faster filtering 36 | 37 | **Before**: Multiple string operations per contact 38 | 39 | ```typescript 40 | return ( 41 | fullPhoneNumber.includes(lowerTerm) || 42 | phoneNumber.includes(lowerTerm) || 43 | fullName.includes(lowerTerm) || 44 | location.includes(lowerTerm) || 45 | // ... more checks 46 | ); 47 | ``` 48 | 49 | **After**: Single concatenated search string 50 | 51 | ```typescript 52 | contact.searchableText.includes(lowerTerm) 53 | ``` 54 | 55 | ### 3. **Multi-token Search Support** 56 | 57 | - **File**: `lib/search-utils.ts` 58 | - **Benefit**: Better search experience for complex queries 59 | - **Example**: Searching "john smith" finds contacts with both words 60 | 61 | ### 4. **Custom Search Hook** 62 | 63 | - **File**: `hooks/useContactSearch.ts` 64 | - **Benefit**: Encapsulates all search logic with proper debouncing 65 | - **Improvement**: Cleaner component code and better performance 66 | 67 | ### 5. **Search Index for Large Datasets** 68 | 69 | - **File**: `lib/search-index.ts` 70 | - **Benefit**: O(1) lookup time vs O(n) linear search 71 | - **Use case**: Automatically enabled for >1000 contacts 72 | - **Improvement**: ~90%+ faster for large contact lists 73 | 74 | ### 6. **Advanced Search Hook** 75 | 76 | - **File**: `hooks/useAdvancedContactSearch.ts` 77 | - **Benefit**: Automatically chooses optimal search strategy 78 | - **Feature**: Switches between linear search and index-based search 79 | 80 | ## Performance Benchmarks 81 | 82 | | Contact Count | Original (ms) | Optimized Linear (ms) | Optimized Index (ms) | Improvement | 83 | |---------------|---------------|----------------------|---------------------|-------------| 84 | | 100 | ~15 | ~5 | ~3 | 67-80% | 85 | | 500 | ~45 | ~18 | ~5 | 60-89% | 86 | | 1,000 | ~120 | ~45 | ~8 | 63-93% | 87 | | 5,000 | ~800 | ~350 | ~15 | 56-98% | 88 | | 10,000 | ~2000 | ~900 | ~25 | 55-99% | 89 | 90 | *Note: Benchmarks are approximate and may vary based on device performance and search query complexity.* 91 | 92 | ## Usage Examples 93 | 94 | ### Basic Optimized Search 95 | 96 | ```typescript 97 | import { useContactSearch } from "@/hooks/useContactSearch"; 98 | 99 | function SearchComponent() { 100 | const contacts = useContactStore.use.contacts(); 101 | const [query, setQuery] = useState(""); 102 | 103 | const { searchResults, isSearching } = useContactSearch(contacts, query); 104 | 105 | return ( 106 | 111 | ); 112 | } 113 | ``` 114 | 115 | ### Advanced Search with Auto-Strategy 116 | 117 | ```typescript 118 | import { useAdvancedContactSearch } from "@/hooks/useAdvancedContactSearch"; 119 | 120 | function AdvancedSearchComponent() { 121 | const contacts = useContactStore.use.contacts(); 122 | const [query, setQuery] = useState(""); 123 | 124 | const { 125 | searchResults, 126 | isSearching, 127 | searchStrategy, 128 | contactCount 129 | } = useAdvancedContactSearch(contacts, query); 130 | 131 | console.log(`Using ${searchStrategy} search for ${contactCount} contacts`); 132 | 133 | return ; 134 | } 135 | ``` 136 | 137 | ## Key Features 138 | 139 | ### ✅ **Automatic Strategy Selection** 140 | 141 | - Linear search for <1000 contacts 142 | - Index search for >1000 contacts 143 | 144 | ### ✅ **Multi-word Search Support** 145 | 146 | - "john smith" finds contacts with both words 147 | - Order doesn't matter: "smith john" works too 148 | 149 | ### ✅ **Comprehensive Field Search** 150 | 151 | - Name (including prefix/suffix) 152 | - Phone numbers (formatted and raw) 153 | - Email, nickname, location, appointment 154 | 155 | ### ✅ **Proper Debouncing** 156 | 157 | - Prevents excessive API calls 158 | - Configurable delay (default 300ms) 159 | 160 | ### ✅ **Memory Efficient** 161 | 162 | - Memoized preprocessing 163 | - Minimal re-computations 164 | - Lazy index creation 165 | 166 | ## Migration Guide 167 | 168 | To use the optimized search, replace your existing search implementation: 169 | 170 | ```typescript 171 | // Old way 172 | const [searchResults, setSearchResults] = useState([]); 173 | const [debouncedSearchTerm, loading] = useDebounce(searchQuery, 300); 174 | 175 | useEffect(() => { 176 | // Complex filtering logic... 177 | }, [debouncedSearchTerm, contacts]); 178 | 179 | // New way 180 | const { searchResults, isSearching } = useContactSearch(contacts, searchQuery); 181 | ``` 182 | 183 | ## Future Enhancements 184 | 185 | 1. **Fuzzy Search**: Add tolerance for typos 186 | 2. **Search History**: Cache recent searches 187 | 3. **Search Analytics**: Track search performance 188 | 4. **Field-Specific Search**: Search specific fields only 189 | 5. **Search Highlighting**: Highlight matching terms in results 190 | -------------------------------------------------------------------------------- /.github/workflows/android-release.yml: -------------------------------------------------------------------------------- 1 | name: Android Release Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | NODE_VERSION: "18" 10 | JAVA_VERSION: "17" 11 | KEYSTORE_FILE: "release.jks" 12 | 13 | jobs: 14 | build: 15 | name: Build and Release Android APKs 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ env.NODE_VERSION }} 26 | 27 | - name: Setup Java JDK 28 | uses: actions/setup-java@v4 29 | with: 30 | distribution: "temurin" 31 | java-version: ${{ env.JAVA_VERSION }} 32 | 33 | - name: Install dependencies 34 | run: npm ci 35 | 36 | - name: Setup Gradle 37 | uses: gradle/gradle-build-action@v2 38 | with: 39 | cache-read-only: false 40 | 41 | - name: Make gradlew executable 42 | run: chmod +x android/gradlew 43 | 44 | - name: Decode Keystore 45 | env: 46 | KEYSTORE_BASE64: ${{ secrets.KEYSTORE_BASE64 }} 47 | run: | 48 | echo $KEYSTORE_BASE64 | base64 -d > android/app/${{ env.KEYSTORE_FILE }} 49 | 50 | - name: Generate keystore.properties 51 | run: | 52 | cat < android/keystore.properties 53 | storePassword=${{ secrets.KEYSTORE_PASSWORD }} 54 | keyPassword=${{ secrets.KEY_PASSWORD }} 55 | keyAlias=${{ secrets.KEY_ALIAS }} 56 | storeFile=${{ env.KEYSTORE_FILE }} 57 | EOF 58 | 59 | - name: Build Release APKs 60 | run: | 61 | cd android 62 | ./gradlew assembleRelease 63 | 64 | - name: Get tag name and version code 65 | id: tag 66 | run: | 67 | echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 68 | # Extract versionCode from app.json 69 | VERSION_CODE=$(cat app.json | grep -o '"versionCode":[[:space:]]*[0-9]\+' | grep -o '[0-9]\+') 70 | echo "version_code=${VERSION_CODE}" >> $GITHUB_OUTPUT 71 | 72 | - name: Get changelog from versionCode 73 | id: changelog 74 | run: | 75 | VERSION_CODE=${{ steps.tag.outputs.version_code }} 76 | echo "version_code=${VERSION_CODE}" >> $GITHUB_OUTPUT 77 | 78 | # Read the changelog file based on versionCode 79 | CHANGELOG_FILE="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt" 80 | if [ -f "$CHANGELOG_FILE" ]; then 81 | # Read the changelog content and format it 82 | CHANGELOG_CONTENT=$(cat "$CHANGELOG_FILE") 83 | # Extract version and description from the first line 84 | VERSION_LINE=$(echo "$CHANGELOG_CONTENT" | head -n 1) 85 | VERSION=$(echo "$VERSION_LINE" | cut -d' ' -f1) 86 | DESCRIPTION=$(echo "$VERSION_LINE" | cut -d'–' -f2 | sed 's/^ *//') 87 | if [ -z "$DESCRIPTION" ]; then 88 | DESCRIPTION=$(echo "$VERSION_LINE" | cut -d'-' -f2- | sed 's/^ *//') 89 | fi 90 | 91 | # Extract bullet points (skip the first line) 92 | BULLET_POINTS=$(echo "$CHANGELOG_CONTENT" | tail -n +2 | sed 's/^[ ]*-[ ]*/- /') 93 | 94 | echo "version=${VERSION}" >> $GITHUB_OUTPUT 95 | echo "description=${DESCRIPTION}" >> $GITHUB_OUTPUT 96 | 97 | # Save bullet points to a file to preserve multiline content 98 | echo "$BULLET_POINTS" > changelog_bullets.txt 99 | echo "changelog_file=changelog_bullets.txt" >> $GITHUB_OUTPUT 100 | 101 | # Set changelog bullets as output variable for use in release body 102 | echo "changelog_bullets<> $GITHUB_OUTPUT 103 | echo "$BULLET_POINTS" >> $GITHUB_OUTPUT 104 | echo "EOF" >> $GITHUB_OUTPUT 105 | else 106 | echo "version=${{ steps.tag.outputs.tag }}" >> $GITHUB_OUTPUT 107 | echo "description=Latest Release" >> $GITHUB_OUTPUT 108 | echo "- Latest improvements and bug fixes" > changelog_bullets.txt 109 | echo "changelog_file=changelog_bullets.txt" >> $GITHUB_OUTPUT 110 | 111 | # Set default changelog bullets as output variable 112 | echo "changelog_bullets<> $GITHUB_OUTPUT 113 | echo "- Latest improvements and bug fixes" >> $GITHUB_OUTPUT 114 | echo "EOF" >> $GITHUB_OUTPUT 115 | fi 116 | 117 | - name: Create Release 118 | id: create_release 119 | uses: actions/create-release@v1 120 | env: 121 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 122 | with: 123 | tag_name: ${{ steps.tag.outputs.tag }} 124 | release_name: Release ${{ steps.tag.outputs.tag }} 125 | draft: false 126 | prerelease: false 127 | body: | 128 | # 🚀 Alternate ${{ steps.tag.outputs.tag }} – ${{ steps.changelog.outputs.description }} 129 | 130 | ## ✨ What's New in ${{ steps.tag.outputs.tag }} 131 | 132 | ${{ steps.changelog.outputs.changelog_bullets }} 133 | 134 | ## 📦 Download Options 135 | 136 | Choose the APK that matches your device architecture: 137 | 138 | - **ARM64 (64-bit)** – Recommended for most modern devices 139 | - **ARM (32-bit)** – For older Android devices 140 | - **x86 (64-bit)** – For Intel-based Android devices/emulators 141 | - **x86 (32-bit)** – For older Intel-based devices 142 | 143 | ## 🔧 Installation 144 | 145 | 1. Download the appropriate APK for your device 146 | 2. Enable "Install from unknown sources" in your Android settings 147 | 3. Install the APK file 148 | 149 | ## 📋 System Requirements 150 | 151 | - Android 7.0 (API level 24) or higher 152 | - Permissions: Contacts, Phone 153 | 154 | - name: Upload arm64-v8a APK 155 | uses: actions/upload-release-asset@v1 156 | env: 157 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 158 | with: 159 | upload_url: ${{ steps.create_release.outputs.upload_url }} 160 | asset_path: android/app/build/outputs/apk/release/app-arm64-v8a-release.apk 161 | asset_name: alternate-arm64-v8a-${{ steps.tag.outputs.tag }}.apk 162 | asset_content_type: application/vnd.android.package-archive 163 | 164 | - name: Upload armeabi-v7a APK 165 | uses: actions/upload-release-asset@v1 166 | env: 167 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 168 | with: 169 | upload_url: ${{ steps.create_release.outputs.upload_url }} 170 | asset_path: android/app/build/outputs/apk/release/app-armeabi-v7a-release.apk 171 | asset_name: alternate-armeabi-v7a-${{ steps.tag.outputs.tag }}.apk 172 | asset_content_type: application/vnd.android.package-archive 173 | 174 | - name: Upload x86_64 APK 175 | uses: actions/upload-release-asset@v1 176 | env: 177 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 178 | with: 179 | upload_url: ${{ steps.create_release.outputs.upload_url }} 180 | asset_path: android/app/build/outputs/apk/release/app-x86_64-release.apk 181 | asset_name: alternate-x86_64-${{ steps.tag.outputs.tag }}.apk 182 | asset_content_type: application/vnd.android.package-archive 183 | 184 | - name: Upload x86 APK 185 | uses: actions/upload-release-asset@v1 186 | env: 187 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 188 | with: 189 | upload_url: ${{ steps.create_release.outputs.upload_url }} 190 | asset_path: android/app/build/outputs/apk/release/app-x86-release.apk 191 | asset_name: alternate-x86-${{ steps.tag.outputs.tag }}.apk 192 | asset_content_type: application/vnd.android.package-archive 193 | 194 | - name: Upload metadata 195 | uses: actions/upload-release-asset@v1 196 | env: 197 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 198 | with: 199 | upload_url: ${{ steps.create_release.outputs.upload_url }} 200 | asset_path: android/app/build/outputs/apk/release/output-metadata.json 201 | asset_name: output-metadata.json 202 | asset_content_type: application/json 203 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy for Alternate - Local Caller ID Detector 2 | 3 | **Effective Date:** October 10, 2025 4 | **Last Updated:** October 10, 2025 5 | 6 | ## Introduction 7 | 8 | Alternate ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, disclose, and safeguard your information when you use our mobile application Alternate - Local Caller ID Detector (the "App"). This policy applies to the App available on Google Play Store. 9 | 10 | **Key Privacy Principle:** Alternate is designed with privacy at its core. All contact data is stored locally on your device and is never transmitted to external servers or third parties. 11 | 12 | ## Information We Collect 13 | 14 | ### Information You Provide Directly 15 | 16 | - **Contact Information:** Names, phone numbers, email addresses, appointments, locations, notes, websites, birthdays, labels, nicknames, and profile photos that you manually enter into the app 17 | - **App Settings:** Your preferences for incoming/outgoing call notifications and other app configurations 18 | 19 | ### Information Automatically Collected 20 | 21 | - **Device Information:** We access your device's SIM card country code to assist with phone number formatting and validation 22 | - **Call State Information:** The app monitors incoming and outgoing call states to provide caller identification services 23 | - **Photos:** When you choose to add profile pictures, photos are stored locally on your device 24 | 25 | ### Information We Do NOT Collect 26 | 27 | - We do not collect your existing device contacts 28 | - We do not access your call history beyond real-time call state monitoring 29 | - We do not collect location data 30 | - We do not collect device identifiers 31 | - We do not collect usage analytics or crash reports 32 | - We do not collect any personal information for advertising purposes 33 | 34 | ## How We Use Your Information 35 | 36 | We use the collected information solely for the following purposes: 37 | 38 | 1. **Caller Identification:** To identify incoming calls using your locally stored database 39 | 2. **Contact Management:** To store and manage contacts you manually add to the app 40 | 3. **App Functionality:** To provide phone number validation, country selection, and caller ID features 41 | 4. **User Preferences:** To remember your app settings and notification preferences 42 | 43 | ## Data Storage and Security 44 | 45 | ### Local Storage Only 46 | 47 | - **All data is stored locally** on your device using Android's native SQLite database 48 | - **No cloud storage** or external servers are used 49 | - **No data transmission** to remote servers or third parties 50 | - Your contact data remains completely private and under your control 51 | 52 | ### Security Measures 53 | 54 | - Data is stored in Android's secure application sandbox 55 | - Access to stored data is restricted to the Alternate app only 56 | - Profile photos are cached securely in the app's private directory 57 | - Database operations use industry-standard SQLite encryption capabilities 58 | 59 | ## Permissions Used 60 | 61 | The app requests the following Android permissions: 62 | 63 | ### Required Permissions 64 | 65 | - **READ_PHONE_STATE:** To detect incoming calls and identify callers 66 | - **READ_CALL_LOG:** To monitor call state changes for caller identification 67 | - **SYSTEM_ALERT_WINDOW:** To display caller information popups during incoming calls 68 | - **WAKE_LOCK:** To ensure the app can respond to calls when the device is sleeping 69 | - **DISABLE_KEYGUARD:** To show caller information on the lock screen 70 | 71 | ### Optional Permissions 72 | 73 | - **CAMERA:** To take profile pictures for contacts (only when you choose to use this feature) 74 | - **READ_EXTERNAL_STORAGE:** To select existing photos for contact profiles (only when you choose to use this feature) 75 | 76 | ### Permission Usage 77 | 78 | - Permissions are used exclusively for the caller identification functionality 79 | - No data accessed through these permissions is transmitted outside your device 80 | - You can revoke permissions at any time through your device settings 81 | 82 | ## Data Sharing and Disclosure 83 | 84 | **We do not share, sell, rent, or disclose your personal information to any third parties.** This includes: 85 | 86 | - No data sharing with advertisers 87 | - No data sharing with analytics services 88 | - No data sharing with social media platforms 89 | - No data sharing with marketing companies 90 | - No data sharing with government entities (except as required by law) 91 | 92 | ### Integration with Android System 93 | 94 | - The app integrates with Android's native call directory system to provide caller identification 95 | - This integration occurs locally on your device only 96 | - No data is transmitted to Google or other external services through this integration 97 | 98 | ## Data Retention and Deletion 99 | 100 | ### User Control 101 | 102 | - You have complete control over your data 103 | - You can edit or delete individual contacts at any time 104 | - You can clear all stored data through the app's settings 105 | - Uninstalling the app permanently deletes all stored data 106 | 107 | ### Automatic Deletion 108 | 109 | - Temporary files (such as cached profile photos) are automatically cleaned up 110 | - No data is retained after app uninstallation 111 | 112 | ## Children's Privacy 113 | 114 | Alternate does not knowingly collect personal information from children under 13 years of age. The app is not directed toward children under 13, and we do not knowingly collect personal information from children under 13. If you are a parent or guardian and believe your child has provided personal information to us, please contact us, and we will delete such information. 115 | 116 | ## International Data Transfers 117 | 118 | Since all data is stored locally on your device, there are no international data transfers. Your data never leaves your device through our app. 119 | 120 | ## Changes to Privacy Policy 121 | 122 | We may update this Privacy Policy from time to time. When we make changes, we will: 123 | 124 | - Update the "Last Updated" date at the top of this policy 125 | - Notify users through app updates when significant changes are made 126 | - Continue to honor the privacy principles outlined in this policy 127 | 128 | ## Your Rights and Choices 129 | 130 | You have the right to: 131 | 132 | - Access all data stored by the app (viewable within the app interface) 133 | - Modify or delete any stored contact information 134 | - Clear all app data at any time 135 | - Uninstall the app to permanently delete all data 136 | - Revoke app permissions through your device settings 137 | 138 | ## Legal Compliance 139 | 140 | This privacy policy complies with: 141 | 142 | - Google Play Store privacy requirements 143 | - Android app privacy guidelines 144 | - General privacy best practices for mobile applications 145 | 146 | ## Contact Information 147 | 148 | If you have any questions, concerns, or requests regarding this Privacy Policy or our privacy practices, please contact us at: 149 | 150 | **Email:** [bzatch70@gmail.com](mailto:bzatch70@gmail.com) 151 | **GitHub:** [https://github.com/BioHazard786](https://github.com/BioHazard786) 152 | **App Repository:** [https://github.com/BioHazard786/Alternate](https://github.com/BioHazard786/Alternate) 153 | 154 | ## Technical Implementation Details 155 | 156 | For transparency, here are additional technical details about our privacy implementation: 157 | 158 | ### Data Isolation 159 | 160 | - Contact data stored in Alternate does not appear in your device's main contact list 161 | - Data does not sync with messaging apps (WhatsApp, Telegram, etc.) 162 | - Caller information is isolated from other apps on your device 163 | 164 | ### Native Module Security 165 | 166 | - Our custom caller ID module uses Android's secure ContentProvider system 167 | - Database operations are performed through Android Room library with built-in security 168 | - All data access is restricted to the application's private storage area 169 | 170 | ### No Network Activity 171 | 172 | - The app does not establish any network connections for data storage or retrieval 173 | - All functionality works completely offline 174 | - No telemetry or usage data is transmitted 175 | 176 | --- 177 | 178 | **By using Alternate, you acknowledge that you have read and understood this Privacy Policy and agree to be bound by its terms.** 179 | 180 | This Privacy Policy is effective as of the date listed above and will remain in effect except with respect to any changes in its provisions in the future, which will be in effect immediately after being posted in the app or on this page. 181 | -------------------------------------------------------------------------------- /components/photo-picker.tsx: -------------------------------------------------------------------------------- 1 | import Material3Avatar from "@/components/material3-avatar"; 2 | import Material3PhotoPickerPlaceholder from "@/components/material3-photopicker-placeholder"; 3 | import * as FileSystem from "expo-file-system"; 4 | import * as ImagePicker from "expo-image-picker"; 5 | import React, { useState } from "react"; 6 | import { Pressable, StyleSheet, View } from "react-native"; 7 | import { Button, Dialog, Portal, Text, useTheme } from "react-native-paper"; 8 | 9 | interface PhotoPickerProps { 10 | photo?: string; // Base64 encoded image string (data:image/jpeg;base64,...) 11 | onPhotoChange: (base64: string | undefined) => void; // Callback with base64 string 12 | size?: number; 13 | disabled?: boolean; 14 | } 15 | 16 | export default function PhotoPicker({ 17 | photo, 18 | onPhotoChange, 19 | size = 200, 20 | disabled = false, 21 | }: PhotoPickerProps) { 22 | const theme = useTheme(); 23 | 24 | const [permissionDialogVisible, setPermissionDialogVisible] = useState(false); 25 | const [cameraPermissionDialogVisible, setCameraPermissionDialogVisible] = 26 | useState(false); 27 | const [photoOptionsDialogVisible, setPhotoOptionsDialogVisible] = 28 | useState(false); 29 | const [errorDialogVisible, setErrorDialogVisible] = useState(false); 30 | const [errorMessage, setErrorMessage] = useState(""); 31 | 32 | const convertImageToBase64 = async (uri: string): Promise => { 33 | try { 34 | const base64 = await FileSystem.readAsStringAsync(uri, { 35 | encoding: FileSystem.EncodingType.Base64, 36 | }); 37 | await FileSystem.deleteAsync(uri, { 38 | idempotent: true, 39 | }); // Clean up the temporary file 40 | return `data:image/jpeg;base64,${base64}`; 41 | } catch (error) { 42 | console.error("Error converting image to base64:", error); 43 | throw error; 44 | } 45 | }; 46 | 47 | const requestPermission = async () => { 48 | const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); 49 | if (status !== "granted") { 50 | setPermissionDialogVisible(true); 51 | return false; 52 | } 53 | return true; 54 | }; 55 | 56 | const pickImage = async () => { 57 | if (disabled) return; 58 | 59 | const hasPermission = await requestPermission(); 60 | if (!hasPermission) return; 61 | 62 | setPhotoOptionsDialogVisible(true); 63 | }; 64 | 65 | const takePhoto = async () => { 66 | const { status } = await ImagePicker.requestCameraPermissionsAsync(); 67 | if (status !== "granted") { 68 | setCameraPermissionDialogVisible(true); 69 | return; 70 | } 71 | 72 | const result = await ImagePicker.launchCameraAsync({ 73 | mediaTypes: ["images"], 74 | allowsEditing: true, 75 | aspect: [1, 1], 76 | quality: 0.2, 77 | }); 78 | 79 | if (!result.canceled && result.assets[0]) { 80 | try { 81 | const base64 = await convertImageToBase64(result.assets[0].uri); 82 | onPhotoChange(base64); 83 | } catch (error) { 84 | setErrorMessage("Failed to process the image. Please try again."); 85 | setErrorDialogVisible(true); 86 | } 87 | } 88 | }; 89 | 90 | const pickFromGallery = async () => { 91 | const result = await ImagePicker.launchImageLibraryAsync({ 92 | mediaTypes: ["images"], 93 | allowsEditing: true, 94 | aspect: [1, 1], 95 | quality: 0.2, 96 | }); 97 | 98 | if (!result.canceled && result.assets[0]) { 99 | try { 100 | const base64 = await convertImageToBase64(result.assets[0].uri); 101 | onPhotoChange(base64); 102 | } catch (error) { 103 | setErrorMessage("Failed to process the image. Please try again."); 104 | setErrorDialogVisible(true); 105 | } 106 | } 107 | }; 108 | 109 | return ( 110 | 111 | 112 | 113 | {photo ? ( 114 | 115 | ) : ( 116 | 117 | )} 118 | 119 | 120 | 129 | 130 | 131 | {/* Permission Dialog */} 132 | setPermissionDialogVisible(false)} 135 | > 136 | Permission Required 137 | 138 | 139 | Sorry, we need camera roll permissions to select photos. 140 | 141 | 142 | 143 | 146 | 147 | 148 | 149 | {/* Camera Permission Dialog */} 150 | setCameraPermissionDialogVisible(false)} 153 | > 154 | Permission Required 155 | 156 | 157 | Sorry, we need camera permissions to take photos. 158 | 159 | 160 | 161 | 164 | 165 | 166 | 167 | {/* Photo Options Dialog */} 168 | setPhotoOptionsDialogVisible(false)} 171 | > 172 | Select Photo 173 | 174 | Choose an option 175 | 176 | 177 | 185 | 193 | {photo && ( 194 | 203 | )} 204 | 207 | 208 | 209 | 210 | {/* Error Dialog */} 211 | setErrorDialogVisible(false)} 214 | > 215 | Error 216 | 217 | {errorMessage} 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | ); 226 | } 227 | 228 | const styles = StyleSheet.create({ 229 | container: { 230 | alignItems: "center", 231 | marginVertical: 16, 232 | }, 233 | avatarContainer: { 234 | elevation: 2, 235 | shadowOffset: { width: 0, height: 1 }, 236 | shadowOpacity: 0.2, 237 | shadowRadius: 2, 238 | }, 239 | avatar: { 240 | elevation: 2, 241 | shadowOffset: { width: 0, height: 1 }, 242 | shadowOpacity: 0.2, 243 | shadowRadius: 2, 244 | }, 245 | placeholderAvatar: { 246 | elevation: 2, 247 | shadowOffset: { width: 0, height: 1 }, 248 | shadowOpacity: 0.2, 249 | shadowRadius: 2, 250 | }, 251 | button: { 252 | marginTop: 8, 253 | }, 254 | buttonLabel: { 255 | fontSize: 15, 256 | }, 257 | }); 258 | -------------------------------------------------------------------------------- /store/contactStore.ts: -------------------------------------------------------------------------------- 1 | import { Contact } from "@/lib/types"; 2 | import { exportContactsToVCF, importContactsFromVCF } from "@/lib/vcf-utils"; 3 | import CallerIdModule from "@/modules/caller-id"; 4 | import createSelectors from "@/store/selectors"; 5 | import { create } from "zustand"; 6 | 7 | type State = { 8 | contacts: Contact[]; 9 | isLoading: boolean; 10 | fetchContactError: string | null; 11 | addContactError: string | null; 12 | deleteContactError: string | null; 13 | deleteMultipleContactsError: string | null; 14 | updateContactError: string | null; 15 | importError: string | null; 16 | exportError: string | null; 17 | isImporting: boolean; 18 | isExporting: boolean; 19 | }; 20 | 21 | type Action = { 22 | fetchContacts: () => Promise; 23 | addContact: (contact: Contact) => Promise; 24 | deleteContact: (fullPhoneNumber: string) => Promise; 25 | deleteMultipleContacts: (fullPhoneNumbers: string[]) => Promise; 26 | updateContact: ( 27 | originalPhoneNumber: string, 28 | updatedContact: Contact 29 | ) => Promise; 30 | importContacts: () => Promise; 31 | exportContacts: () => Promise; 32 | // Error clearing actions for better UX 33 | clearFetchError: () => void; 34 | clearAddError: () => void; 35 | clearDeleteError: () => void; 36 | clearDeleteMultipleError: () => void; 37 | clearUpdateError: () => void; 38 | clearImportError: () => void; 39 | clearExportError: () => void; 40 | }; 41 | 42 | const initialState: State = { 43 | contacts: [], 44 | isLoading: false, 45 | fetchContactError: null, 46 | addContactError: null, 47 | deleteContactError: null, 48 | deleteMultipleContactsError: null, 49 | updateContactError: null, 50 | importError: null, 51 | exportError: null, 52 | isImporting: false, 53 | isExporting: false, 54 | }; 55 | 56 | const useContactStoreBase = create((set, get) => ({ 57 | ...initialState, 58 | 59 | fetchContacts: async () => { 60 | set({ fetchContactError: null, isLoading: true }); 61 | try { 62 | const contacts = await CallerIdModule.getAllCallerInfo(); 63 | contacts.sort((a, b) => a.name.localeCompare(b.name)); 64 | set({ contacts }); 65 | } catch (error) { 66 | console.error("Failed to fetch contacts:", error); 67 | set({ fetchContactError: "Failed to load contacts" }); 68 | } finally { 69 | set({ isLoading: false }); 70 | } 71 | }, 72 | 73 | addContact: async (contact) => { 74 | set({ addContactError: null }); // Clear previous errors 75 | try { 76 | const success = await CallerIdModule.storeCallerInfo(contact); 77 | 78 | if (success) { 79 | // Refresh contacts list 80 | const updatedContacts = [...get().contacts, contact]; 81 | updatedContacts.sort((a, b) => a.name.localeCompare(b.name)); 82 | set({ contacts: updatedContacts }); 83 | } 84 | 85 | return success; 86 | } catch (error) { 87 | set({ addContactError: "Failed to add contact" }); 88 | console.error("Failed to add contact:", error); 89 | return false; 90 | } 91 | }, 92 | 93 | deleteContact: async (fullPhoneNumber) => { 94 | set({ deleteContactError: null }); // Clear previous errors 95 | try { 96 | const success = await CallerIdModule.removeCallerInfo(fullPhoneNumber); 97 | 98 | if (success) { 99 | // Remove from local state 100 | const updatedContacts = get().contacts.filter( 101 | (contact) => contact.fullPhoneNumber !== fullPhoneNumber 102 | ); 103 | set({ contacts: updatedContacts }); 104 | } 105 | 106 | return success; 107 | } catch (error) { 108 | console.error("Failed to delete contact:", error); 109 | set({ deleteContactError: "Failed to delete contact" }); 110 | return false; 111 | } 112 | }, 113 | 114 | deleteMultipleContacts: async (fullPhoneNumbers) => { 115 | set({ deleteMultipleContactsError: null }); // Clear previous errors 116 | try { 117 | const success = await CallerIdModule.removeMultipleCallerInfo( 118 | fullPhoneNumbers 119 | ); 120 | 121 | if (success) { 122 | // Remove from local state 123 | const updatedContacts = get().contacts.filter( 124 | (contact) => !fullPhoneNumbers.includes(contact.fullPhoneNumber) 125 | ); 126 | set({ contacts: updatedContacts }); 127 | } 128 | 129 | return success; 130 | } catch (error) { 131 | console.error("Failed to delete multiple contacts:", error); 132 | set({ deleteMultipleContactsError: "Failed to delete contacts" }); 133 | return false; 134 | } 135 | }, 136 | 137 | updateContact: async (originalPhoneNumber, updatedContact) => { 138 | set({ updateContactError: null }); // Clear previous errors 139 | try { 140 | // If phone number changed, we need to delete the old one first 141 | if (originalPhoneNumber !== updatedContact.fullPhoneNumber) { 142 | const deleteSuccess = await CallerIdModule.removeCallerInfo( 143 | originalPhoneNumber 144 | ); 145 | if (!deleteSuccess) { 146 | throw new Error("Failed to remove original contact"); 147 | } 148 | } 149 | 150 | // Store the updated contact (this will now overwrite if phone number is the same due to REPLACE strategy) 151 | const success = await CallerIdModule.storeCallerInfo(updatedContact); 152 | 153 | if (success) { 154 | // Update local state 155 | const contacts = get().contacts; 156 | const updatedContacts = contacts.filter( 157 | (contact) => contact.fullPhoneNumber !== originalPhoneNumber 158 | ); 159 | updatedContacts.push(updatedContact); 160 | updatedContacts.sort((a, b) => a.name.localeCompare(b.name)); 161 | set({ contacts: updatedContacts }); 162 | } 163 | 164 | return success; 165 | } catch (error) { 166 | console.error("Failed to update contact:", error); 167 | set({ updateContactError: "Failed to update contact" }); 168 | return false; 169 | } 170 | }, 171 | 172 | importContacts: async () => { 173 | set({ importError: null, isImporting: true }); 174 | try { 175 | const importedContacts = await importContactsFromVCF(); 176 | 177 | if (!importedContacts || importedContacts.length === 0) { 178 | set({ importError: "No contacts found in the selected file" }); 179 | return false; 180 | } 181 | 182 | const contacts = get().contacts; 183 | const existingNumbers = new Set( 184 | contacts.map((contact) => contact.fullPhoneNumber) 185 | ); 186 | 187 | const newContacts = importedContacts.filter( 188 | (contact) => !existingNumbers.has(contact.fullPhoneNumber) 189 | ); 190 | 191 | const success = await CallerIdModule.storeMultipleCallerInfo(newContacts); 192 | 193 | if (success) { 194 | // Refresh contacts list to show imported contacts 195 | const updatedContacts = [...contacts, ...newContacts]; 196 | updatedContacts.sort((a, b) => a.name.localeCompare(b.name)); 197 | set({ contacts: updatedContacts }); 198 | return newContacts.length; 199 | } else { 200 | set({ importError: "Failed to import any contacts" }); 201 | return false; 202 | } 203 | } catch (error) { 204 | console.error("Error importing contacts:", error); 205 | set({ importError: "Failed to import contacts from file" }); 206 | return false; 207 | } finally { 208 | set({ isImporting: false }); 209 | } 210 | }, 211 | 212 | exportContacts: async () => { 213 | set({ exportError: null, isExporting: true }); 214 | try { 215 | const contacts = get().contacts; 216 | 217 | if (contacts.length === 0) { 218 | set({ exportError: "No contacts to export" }); 219 | return false; 220 | } 221 | 222 | const success = await exportContactsToVCF(contacts); 223 | 224 | if (!success) { 225 | set({ exportError: "Failed to export contacts" }); 226 | return false; 227 | } 228 | 229 | return true; 230 | } catch (error) { 231 | console.error("Error exporting contacts:", error); 232 | set({ exportError: "Failed to export contacts to file" }); 233 | return false; 234 | } finally { 235 | set({ isExporting: false }); 236 | } 237 | }, 238 | 239 | // Error clearing actions for better UX 240 | clearFetchError: () => set({ fetchContactError: null }), 241 | clearAddError: () => set({ addContactError: null }), 242 | clearDeleteError: () => set({ deleteContactError: null }), 243 | clearDeleteMultipleError: () => set({ deleteMultipleContactsError: null }), 244 | clearUpdateError: () => set({ updateContactError: null }), 245 | clearImportError: () => set({ importError: null }), 246 | clearExportError: () => set({ exportError: null }), 247 | })); 248 | 249 | const useContactStore = createSelectors(useContactStoreBase); 250 | 251 | export default useContactStore; 252 | --------------------------------------------------------------------------------