├── .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 |
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 |
148 |
149 | {/* Camera Permission Dialog */}
150 |
166 |
167 | {/* Photo Options Dialog */}
168 |
209 |
210 | {/* Error Dialog */}
211 |
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 |
--------------------------------------------------------------------------------