Compare commits
52 Commits
merge_bart
...
dev
Author | SHA1 | Date | |
---|---|---|---|
ec1779bb15 | |||
22941f78d0 | |||
b9dd156eca | |||
58ccd3a24c | |||
608218175a | |||
37349fdc13 | |||
fb5f155430 | |||
2ea2c679b2 | |||
2d3519592a | |||
3a7c9718dd | |||
615fc9bd90 | |||
8fe630dc59 | |||
399870d218 | |||
21f470a464 | |||
6ada4f6e5c | |||
23c1839e7b | |||
ecf4ea16d8 | |||
b15ae302b6 | |||
84329cb4d0 | |||
f3f5c70620 | |||
f99fdaa160 | |||
ee1f2ee4b1 | |||
e164f68bb8 | |||
475f432047 | |||
cc849c83b1 | |||
5a8cfc055d | |||
8b1eb1d709 | |||
be20139cc4 | |||
7b5448905c | |||
0da5e27830 | |||
7e438f3ee7 | |||
df8cd1ea54 | |||
e05880c9d8 | |||
e8aba933d0 | |||
518e49b3e6 | |||
d8c9585f85 | |||
09fa0a0216 | |||
21b6b0a29a | |||
fca1eea1c9 | |||
3f6ea2e332 | |||
13376118c3 | |||
9cd0c612dd | |||
0f18a0a62c | |||
d54edd50fd | |||
50235b13c5 | |||
|
4359057c1d | ||
64595a1755 | |||
c0ec11098d | |||
f0426b0246 | |||
347148c433 | |||
74d9eda464 | |||
edec5cf531 |
31
.gitea/workflows/apk.yaml
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- dialer/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: debian
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
with:
|
||||||
|
subpath: dialer/
|
||||||
|
- uses: docker://git.gmoker.com/icing/flutter:main
|
||||||
|
- uses: actions/upload-artifact@v1
|
||||||
|
with:
|
||||||
|
name: icing-dialer-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk
|
||||||
|
path: build/app/outputs/flutter-apk/app-release.apk
|
||||||
|
|
||||||
|
build-stealth:
|
||||||
|
runs-on: debian
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
with:
|
||||||
|
subpath: dialer/
|
||||||
|
- uses: docker://git.gmoker.com/icing/flutter:main
|
||||||
|
with:
|
||||||
|
args: "build apk --dart-define=STEALTH=true"
|
||||||
|
- uses: actions/upload-artifact@v1
|
||||||
|
with:
|
||||||
|
name: icing-dialer-stealth-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk
|
||||||
|
path: build/app/outputs/flutter-apk/app-release.apk
|
11
.gitea/workflows/mirror.yaml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
on: push
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
mirror:
|
||||||
|
runs-on: debian
|
||||||
|
steps:
|
||||||
|
- uses: actions/mirror@v1
|
||||||
|
with:
|
||||||
|
ssh_priv: "${{ secrets.SSHGH }}"
|
||||||
|
known_hosts: "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
|
||||||
|
url: "git@github.com:EpitechPromo2026/G-EIP-700-TLS-7-1-eip-stephane.corbiere.git"
|
35
.gitea/workflows/website.yaml
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- website/**
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: debian
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v1
|
||||||
|
with:
|
||||||
|
subpath: website/
|
||||||
|
- name: setup env
|
||||||
|
run: |
|
||||||
|
. ./.env || true
|
||||||
|
if [ "${{ gitea.ref_name }}" == prod ] && [ -n "$PROD_URL" ]; then
|
||||||
|
BASE_URL="$PROD_URL"
|
||||||
|
else
|
||||||
|
BASE_URL="${{ gitea.ref_name }}.$(tr / '\n' <<< "${{ gitea.repository }}" | tac | tr '\n' .)k8s.gmoker.com"
|
||||||
|
fi
|
||||||
|
REGISTRY="$(sed 's .*:// ' <<< ${{ gitea.server_url }})"
|
||||||
|
cat <<EOF >> .env
|
||||||
|
BASE_URL="$(printf '%s' "$BASE_URL" | tr '[:upper:]' '[:lower:]' | tr -c '[:lower:][:digit:]-.' -)"
|
||||||
|
IMAGEAPP="$REGISTRY/$(printf '%s' "${{ gitea.repository }}:${{ gitea.ref_name }}" | tr '[:upper:]' '[:lower:]' | tr -c '[:lower:][:digit:]-/:_' _)"
|
||||||
|
EOF
|
||||||
|
cat .env
|
||||||
|
|
||||||
|
- uses: actions/kaniko@v1
|
||||||
|
with:
|
||||||
|
password: "${{ secrets.PKGRW }}"
|
||||||
|
|
||||||
|
- uses: actions/k8sdeploy@v1
|
||||||
|
with:
|
||||||
|
kubeconfig: "${{ secrets.K8S }}"
|
||||||
|
registry_password: "${{ secrets.PKGRW }}"
|
4
.gitignore
vendored
@ -181,4 +181,6 @@ app.*.symbols
|
|||||||
!**/ios/**/default.perspectivev3
|
!**/ios/**/default.perspectivev3
|
||||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||||
!/dev/ci/**/Gemfile.lock
|
!/dev/ci/**/Gemfile.lock
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
dialer/android/gradle.properties
|
||||||
|
.gitignore
|
||||||
|
22
README.md
@ -1,5 +1,21 @@
|
|||||||
# ICING Dialer
|
# Icing
|
||||||
|
|
||||||
Project aiming to <i>ice</i> your **non-internet** phone calls, thanks to a custom and super-cryptographic dialer app.
|
## Encrypting phone calls on an analog audio level
|
||||||
|
|
||||||
Like & Follow !
|
An Epitech Innovation Project
|
||||||
|
|
||||||
|
*By*
|
||||||
|
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
The **docs** folder contains documentation about:
|
||||||
|
|
||||||
|
#### Epitech
|
||||||
|
- The Beta Test Plan
|
||||||
|
- The Delivrables
|
||||||
|
|
||||||
|
#### Icing
|
||||||
|
- The project
|
||||||
|
- A user manual
|
||||||
|
- Our automations
|
||||||
|
@ -1,47 +0,0 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
|
||||||
<application
|
|
||||||
android:label="com.example.dialer"
|
|
||||||
android:name="${applicationName}"
|
|
||||||
android:icon="@mipmap/ic_launcher">
|
|
||||||
<activity
|
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
|
||||||
android:resource="@style/NormalTheme"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
</application>
|
|
||||||
<!-- Required to query activities that can process text, see:
|
|
||||||
https://developer.android.com/training/package-visibility and
|
|
||||||
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
|
||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
|
||||||
<queries>
|
|
||||||
<intent>
|
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
|
||||||
<data android:mimeType="text/plain"/>
|
|
||||||
</intent>
|
|
||||||
</queries>
|
|
||||||
</manifest>
|
|
@ -1,5 +0,0 @@
|
|||||||
package com.example.dialer
|
|
||||||
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
|
|
||||||
class MainActivity: FlutterActivity()
|
|
Before Width: | Height: | Size: 544 B |
Before Width: | Height: | Size: 442 B |
Before Width: | Height: | Size: 721 B |
Before Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.4 KiB |
@ -4,7 +4,10 @@ gradle-wrapper.jar
|
|||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/gradle.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
|
gradle.properties
|
||||||
|
.cxx
|
||||||
|
|
||||||
# Remember to never publicly share your keystore.
|
# Remember to never publicly share your keystore.
|
||||||
# See https://flutter.dev/to/reference-keystore
|
# See https://flutter.dev/to/reference-keystore
|
@ -6,22 +6,22 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.example.dialer"
|
namespace = "com.icing.dialer"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_1_8
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_1_8
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_1_8
|
jvmTarget = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
applicationId = "com.example.dialer"
|
applicationId = "com.icing.dialer"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = flutter.minSdkVersion
|
@ -1,6 +1,11 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
103
dialer/android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer">
|
||||||
|
<uses-feature android:name="android.hardware.telephony" android:required="true" />
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||||
|
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
|
||||||
|
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||||
|
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||||
|
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="Icing Dialer"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:enableOnBackInvokedCallback="true">
|
||||||
|
<activity
|
||||||
|
android:name=".activities.MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:taskAffinity=""
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
|
the Android process has started. This theme is visible to the user
|
||||||
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
|
to determine the Window background behind the Flutter UI. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
|
android:resource="@style/NormalTheme"
|
||||||
|
/>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
<!-- Dialer intent filters (required for default dialer eligibility) -->
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DIAL" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.DIAL" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:scheme="tel" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.CALL" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="tel" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
<service
|
||||||
|
android:name=".services.MyInCallService"
|
||||||
|
android:permission="android.permission.BIND_INCALL_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.telecom.InCallService" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.telecom.IN_CALL_SERVICE_UI"
|
||||||
|
android:value="true" />
|
||||||
|
</service>
|
||||||
|
<!-- Custom ConnextionService, will be needed at some point when we implement our own protocol -->
|
||||||
|
<!-- <service
|
||||||
|
android:name=".services.CallConnectionService"
|
||||||
|
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.telecom.ConnectionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service> -->
|
||||||
|
|
||||||
|
<!-- Don't delete the meta-data below.
|
||||||
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
<!-- Required to query activities that can process text, see:
|
||||||
|
https://developer.android.com/training/package-visibility and
|
||||||
|
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
|
||||||
|
|
||||||
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
|
<data android:mimeType="text/plain"/>
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
</manifest>
|
@ -0,0 +1,28 @@
|
|||||||
|
package com.icing.dialer
|
||||||
|
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
object KeyDeleterHelper {
|
||||||
|
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the key pair associated with the given alias from the Android Keystore.
|
||||||
|
*
|
||||||
|
* @param alias The alias of the key pair to delete.
|
||||||
|
* @throws Exception if deletion fails.
|
||||||
|
*/
|
||||||
|
fun deleteKeyPair(alias: String) {
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
if (!keyStore.containsAlias(alias)) {
|
||||||
|
throw Exception("No key found with alias \"$alias\" to delete.")
|
||||||
|
}
|
||||||
|
|
||||||
|
keyStore.deleteEntry(alias)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to delete key pair: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package com.icing.dialer
|
||||||
|
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
object KeyGeneratorHelper {
|
||||||
|
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an ECDSA P-256 key pair and stores it in the Android Keystore.
|
||||||
|
*
|
||||||
|
* @param alias Unique identifier for the key pair.
|
||||||
|
* @throws Exception if key generation fails.
|
||||||
|
*/
|
||||||
|
fun generateECKeyPair(alias: String) {
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
// Check if the key already exists
|
||||||
|
if (keyStore.containsAlias(alias)) {
|
||||||
|
throw Exception("Key with alias \"$alias\" already exists.")
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyPairGenerator = KeyPairGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_EC,
|
||||||
|
ANDROID_KEYSTORE
|
||||||
|
)
|
||||||
|
|
||||||
|
val parameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
alias,
|
||||||
|
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||||
|
)
|
||||||
|
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA384, KeyProperties.DIGEST_SHA512)
|
||||||
|
.setUserAuthenticationRequired(false) // Set to true if you require user authentication
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyPairGenerator.initialize(parameterSpec)
|
||||||
|
keyPairGenerator.generateKeyPair()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to generate EC key pair: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,154 @@
|
|||||||
|
package com.icing.dialer
|
||||||
|
|
||||||
|
import java.security.PrivateKey
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import io.flutter.plugin.common.MethodCall
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.security.KeyPairGenerator
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.Signature
|
||||||
|
|
||||||
|
class KeystoreHelper(private val call: MethodCall, private val result: MethodChannel.Result) {
|
||||||
|
|
||||||
|
private val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
fun handleMethodCall() {
|
||||||
|
when (call.method) {
|
||||||
|
"generateKeyPair" -> generateECKeyPair()
|
||||||
|
"signData" -> signData()
|
||||||
|
"getPublicKey" -> getPublicKey()
|
||||||
|
"deleteKeyPair" -> deleteKeyPair()
|
||||||
|
"keyPairExists" -> keyPairExists()
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun generateECKeyPair() {
|
||||||
|
val alias = call.argument<String>("alias")
|
||||||
|
if (alias == null) {
|
||||||
|
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
if (keyStore.containsAlias(alias)) {
|
||||||
|
result.error("KEY_EXISTS", "Key with alias \"$alias\" already exists.", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyPairGenerator = KeyPairGenerator.getInstance(
|
||||||
|
KeyProperties.KEY_ALGORITHM_EC,
|
||||||
|
ANDROID_KEYSTORE
|
||||||
|
)
|
||||||
|
|
||||||
|
val parameterSpec = KeyGenParameterSpec.Builder(
|
||||||
|
alias,
|
||||||
|
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
|
||||||
|
)
|
||||||
|
.setAlgorithmParameterSpec(java.security.spec.ECGenParameterSpec("secp256r1"))
|
||||||
|
.setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA384, KeyProperties.DIGEST_SHA512)
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyPairGenerator.initialize(parameterSpec)
|
||||||
|
keyPairGenerator.generateKeyPair()
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("KEY_GENERATION_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signData() {
|
||||||
|
val alias = call.argument<String>("alias")
|
||||||
|
val data = call.argument<String>("data")
|
||||||
|
if (alias == null || data == null) {
|
||||||
|
result.error("INVALID_ARGUMENT", "Alias and data are required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
val privateKey = keyStore.getKey(alias, null) as? PrivateKey ?: run {
|
||||||
|
result.error("KEY_NOT_FOUND", "Private key not found for alias \"$alias\".", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val signature = Signature.getInstance("SHA256withECDSA")
|
||||||
|
signature.initSign(privateKey)
|
||||||
|
signature.update(data.toByteArray())
|
||||||
|
val signedBytes = signature.sign()
|
||||||
|
|
||||||
|
val signatureBase64 = Base64.encodeToString(signedBytes, Base64.DEFAULT)
|
||||||
|
result.success(signatureBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("SIGNING_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPublicKey() {
|
||||||
|
val alias = call.argument<String>("alias")
|
||||||
|
if (alias == null) {
|
||||||
|
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
val certificate = keyStore.getCertificate(alias) ?: run {
|
||||||
|
result.error("CERTIFICATE_NOT_FOUND", "Certificate not found for alias \"$alias\".", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val publicKey = certificate.publicKey
|
||||||
|
val publicKeyBase64 = Base64.encodeToString(publicKey.encoded, Base64.DEFAULT)
|
||||||
|
result.success(publicKeyBase64)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("PUBLIC_KEY_RETRIEVAL_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteKeyPair() {
|
||||||
|
val alias = call.argument<String>("alias")
|
||||||
|
if (alias == null) {
|
||||||
|
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
if (!keyStore.containsAlias(alias)) {
|
||||||
|
result.error("KEY_NOT_FOUND", "No key found with alias \"$alias\" to delete.", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keyStore.deleteEntry(alias)
|
||||||
|
result.success(null)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("KEY_DELETION_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun keyPairExists() {
|
||||||
|
val alias = call.argument<String>("alias")
|
||||||
|
if (alias == null) {
|
||||||
|
result.error("INVALID_ARGUMENT", "Alias is required", null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
val exists = keyStore.containsAlias(alias)
|
||||||
|
result.success(exists)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
result.error("KEY_CHECK_FAILED", e.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
package com.icing.dialer
|
||||||
|
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.PublicKey
|
||||||
|
import android.util.Base64
|
||||||
|
|
||||||
|
object PublicKeyHelper {
|
||||||
|
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the public key associated with the given alias.
|
||||||
|
*
|
||||||
|
* @param alias The alias of the key pair.
|
||||||
|
* @return The public key as a Base64-encoded string.
|
||||||
|
* @throws Exception if retrieval fails.
|
||||||
|
*/
|
||||||
|
fun getPublicKey(alias: String): String {
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
val certificate = keyStore.getCertificate(alias) ?: throw Exception("Certificate not found for alias \"$alias\".")
|
||||||
|
val publicKey: PublicKey = certificate.publicKey
|
||||||
|
|
||||||
|
return Base64.encodeToString(publicKey.encoded, Base64.DEFAULT)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to retrieve public key: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
package com.icing.dialer
|
||||||
|
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.Signature
|
||||||
|
import android.util.Base64
|
||||||
|
import java.security.PrivateKey
|
||||||
|
|
||||||
|
object SignerHelper {
|
||||||
|
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the provided data using the private key associated with the given alias.
|
||||||
|
*
|
||||||
|
* @param alias The alias of the key pair.
|
||||||
|
* @param data The data to sign.
|
||||||
|
* @return The signature as a Base64-encoded string.
|
||||||
|
* @throws Exception if signing fails.
|
||||||
|
*/
|
||||||
|
fun signData(alias: String, data: ByteArray): String {
|
||||||
|
try {
|
||||||
|
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }
|
||||||
|
|
||||||
|
val privateKey = keyStore.getKey(alias, null) as? PrivateKey?: throw Exception("Private key not found for alias \"$alias\".")
|
||||||
|
|
||||||
|
val signature = Signature.getInstance("SHA256withECDSA")
|
||||||
|
signature.initSign(privateKey)
|
||||||
|
signature.update(data)
|
||||||
|
val signedBytes = signature.sign()
|
||||||
|
|
||||||
|
return Base64.encodeToString(signedBytes, Base64.DEFAULT)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to sign data: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,286 @@
|
|||||||
|
package com.icing.dialer.activities
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.role.RoleManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.CallLog
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.icing.dialer.KeystoreHelper
|
||||||
|
import com.icing.dialer.services.CallService
|
||||||
|
import com.icing.dialer.services.MyInCallService
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity() {
|
||||||
|
private val KEYSTORE_CHANNEL = "com.example.keystore"
|
||||||
|
private val CALLLOG_CHANNEL = "com.example.calllog"
|
||||||
|
private val CALL_CHANNEL = "call_service"
|
||||||
|
private val TAG = "MainActivity"
|
||||||
|
private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001
|
||||||
|
private val REQUEST_CODE_CALL_LOG_PERMISSION = 1002
|
||||||
|
private var pendingIncomingCall: Pair<String?, Boolean>? = null
|
||||||
|
private var wasPhoneLocked: Boolean = false
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Log.d(TAG, "onCreate started")
|
||||||
|
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
|
||||||
|
Log.d(TAG, "Was phone locked at start: $wasPhoneLocked")
|
||||||
|
updateLockScreenFlags(intent)
|
||||||
|
handleIncomingCallIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
setIntent(intent)
|
||||||
|
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
|
||||||
|
Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked")
|
||||||
|
updateLockScreenFlags(intent)
|
||||||
|
handleIncomingCallIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateLockScreenFlags(intent: Intent?) {
|
||||||
|
val isIncomingCall = intent?.getBooleanExtra("isIncomingCall", false) ?: false
|
||||||
|
if (isIncomingCall && wasPhoneLocked) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(true)
|
||||||
|
setTurnScreenOn(true)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.addFlags(
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Enabled showWhenLocked and turnScreenOn for incoming call")
|
||||||
|
} else {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||||
|
setShowWhenLocked(false)
|
||||||
|
setTurnScreenOn(false)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
window.clearFlags(
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||||
|
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Disabled showWhenLocked and turnScreenOn for normal usage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
Log.d(TAG, "Configuring Flutter engine")
|
||||||
|
MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
when (call.method) {
|
||||||
|
"permissionsGranted" -> {
|
||||||
|
Log.d(TAG, "Received permissionsGranted from Flutter")
|
||||||
|
pendingIncomingCall?.let { (phoneNumber, showScreen) ->
|
||||||
|
if (showScreen) {
|
||||||
|
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
|
||||||
|
"phoneNumber" to phoneNumber,
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
pendingIncomingCall = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkAndRequestDefaultDialer()
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
"makeGsmCall" -> {
|
||||||
|
val phoneNumber = call.argument<String>("phoneNumber")
|
||||||
|
if (phoneNumber != null) {
|
||||||
|
val success = CallService.makeGsmCall(this, phoneNumber)
|
||||||
|
if (success) {
|
||||||
|
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
|
||||||
|
} else {
|
||||||
|
result.error("CALL_FAILED", "Failed to initiate call", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.error("INVALID_PHONE_NUMBER", "Phone number is required", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"hangUpCall" -> {
|
||||||
|
val success = MyInCallService.currentCall?.let {
|
||||||
|
it.disconnect()
|
||||||
|
Log.d(TAG, "Call disconnected")
|
||||||
|
MyInCallService.channel?.invokeMethod("callEnded", mapOf(
|
||||||
|
"callId" to it.details.handle.toString(),
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
if (success) {
|
||||||
|
result.success(mapOf("status" to "ended"))
|
||||||
|
if (wasPhoneLocked) {
|
||||||
|
Log.d(TAG, "Finishing and removing task after hangup, phone was locked")
|
||||||
|
finishAndRemoveTask()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No active call to hang up")
|
||||||
|
result.error("HANGUP_FAILED", "No active call to hang up", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"answerCall" -> {
|
||||||
|
val success = MyInCallService.currentCall?.let {
|
||||||
|
it.answer(0)
|
||||||
|
Log.d(TAG, "Answered call")
|
||||||
|
true
|
||||||
|
} ?: false
|
||||||
|
if (success) {
|
||||||
|
result.success(mapOf("status" to "answered"))
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "No active call to answer")
|
||||||
|
result.error("ANSWER_FAILED", "No active call to answer", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"callEndedFromFlutter" -> {
|
||||||
|
Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked")
|
||||||
|
if (wasPhoneLocked) {
|
||||||
|
finishAndRemoveTask()
|
||||||
|
Log.d(TAG, "Finishing and removing task after call ended, phone was locked")
|
||||||
|
}
|
||||||
|
result.success(true)
|
||||||
|
}
|
||||||
|
else -> result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
KeystoreHelper(call, result).handleMethodCall()
|
||||||
|
}
|
||||||
|
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method == "getCallLogs") {
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
val callLogs = getCallLogs()
|
||||||
|
result.success(callLogs)
|
||||||
|
} else {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
|
||||||
|
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.notImplemented()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkAndRequestDefaultDialer() {
|
||||||
|
val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager
|
||||||
|
val currentDefault = telecomManager.defaultDialerPackage
|
||||||
|
Log.d(TAG, "Current default dialer: $currentDefault, My package: $packageName")
|
||||||
|
|
||||||
|
if (currentDefault != packageName) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val roleManager = getSystemService(Context.ROLE_SERVICE) as RoleManager
|
||||||
|
if (roleManager.isRoleAvailable(RoleManager.ROLE_DIALER) && !roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) {
|
||||||
|
val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER)
|
||||||
|
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
|
||||||
|
Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER)
|
||||||
|
.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName)
|
||||||
|
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
try {
|
||||||
|
startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER)
|
||||||
|
Log.d(TAG, "Launched TelecomManager intent for default dialer")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to launch default dialer prompt: ${e.message}", e)
|
||||||
|
launchDefaultAppsSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "Already the default dialer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchDefaultAppsSettings() {
|
||||||
|
val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS)
|
||||||
|
startActivity(settingsIntent)
|
||||||
|
Log.d(TAG, "Opened default apps settings as fallback")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
|
Log.d(TAG, "onActivityResult: requestCode=$requestCode, resultCode=$resultCode, data=$data")
|
||||||
|
if (requestCode == REQUEST_CODE_SET_DEFAULT_DIALER) {
|
||||||
|
if (resultCode == RESULT_OK) {
|
||||||
|
Log.d(TAG, "User accepted default dialer change")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Default dialer prompt canceled or failed (resultCode=$resultCode)")
|
||||||
|
launchDefaultAppsSettings()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == REQUEST_CODE_CALL_LOG_PERMISSION) {
|
||||||
|
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
Log.d(TAG, "Call log permission granted")
|
||||||
|
MyInCallService.channel?.invokeMethod("callLogPermissionGranted", null)
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Call log permission denied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getCallLogs(): List<Map<String, Any?>> {
|
||||||
|
val logsList = mutableListOf<Map<String, Any?>>()
|
||||||
|
val cursor: Cursor? = contentResolver.query(
|
||||||
|
CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC"
|
||||||
|
)
|
||||||
|
cursor?.use {
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
|
||||||
|
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
|
||||||
|
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
|
||||||
|
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
|
||||||
|
|
||||||
|
val map = mutableMapOf<String, Any?>(
|
||||||
|
"number" to number,
|
||||||
|
"type" to type,
|
||||||
|
"date" to date,
|
||||||
|
"duration" to duration
|
||||||
|
)
|
||||||
|
logsList.add(map)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return logsList
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleIncomingCallIntent(intent: Intent?) {
|
||||||
|
intent?.let {
|
||||||
|
if (it.getBooleanExtra("isIncomingCall", false)) {
|
||||||
|
val phoneNumber = it.getStringExtra("phoneNumber")
|
||||||
|
val showScreen = it.getBooleanExtra("showIncomingCallScreen", false)
|
||||||
|
Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked")
|
||||||
|
if (showScreen) {
|
||||||
|
if (MyInCallService.channel != null) {
|
||||||
|
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
|
||||||
|
"phoneNumber" to phoneNumber,
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
pendingIncomingCall = Pair(phoneNumber, true)
|
||||||
|
Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
// package com.icing.dialer.services
|
||||||
|
|
||||||
|
// import android.telecom.Connection
|
||||||
|
// import android.telecom.ConnectionService
|
||||||
|
// import android.telecom.PhoneAccountHandle
|
||||||
|
// import android.telecom.TelecomManager
|
||||||
|
// import android.telecom.DisconnectCause
|
||||||
|
// import android.util.Log
|
||||||
|
// import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
// class CallConnectionService : ConnectionService() {
|
||||||
|
// companion object {
|
||||||
|
// var channel: MethodChannel? = null
|
||||||
|
// private const val TAG = "CallConnectionService"
|
||||||
|
// }
|
||||||
|
|
||||||
|
// init {
|
||||||
|
// Log.d(TAG, "CallConnectionService initialized")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onCreate() {
|
||||||
|
// super.onCreate()
|
||||||
|
// Log.d(TAG, "Service created")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onDestroy() {
|
||||||
|
// super.onDestroy()
|
||||||
|
// Log.d(TAG, "Service destroyed")
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onCreateOutgoingConnection(
|
||||||
|
// connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||||
|
// request: android.telecom.ConnectionRequest
|
||||||
|
// ): Connection {
|
||||||
|
// Log.d(TAG, "Creating outgoing connection for ${request.address}, account: $connectionManagerPhoneAccount")
|
||||||
|
// val connection = object : Connection() {
|
||||||
|
// override fun onStateChanged(state: Int) {
|
||||||
|
// super.onStateChanged(state)
|
||||||
|
// Log.d(TAG, "Connection state changed: $state")
|
||||||
|
// val stateStr = when (state) {
|
||||||
|
// STATE_DIALING -> "dialing"
|
||||||
|
// STATE_ACTIVE -> "active"
|
||||||
|
// STATE_DISCONNECTED -> "disconnected"
|
||||||
|
// else -> "unknown"
|
||||||
|
// }
|
||||||
|
// channel?.invokeMethod("callStateChanged", mapOf("state" to stateStr, "phoneNumber" to request.address.toString()))
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onDisconnect() {
|
||||||
|
// Log.d(TAG, "Connection disconnected")
|
||||||
|
// setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
|
||||||
|
// destroy()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
// connection.setInitialized()
|
||||||
|
// connection.setDialing()
|
||||||
|
// return connection
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onCreateIncomingConnection(
|
||||||
|
// connectionManagerPhoneAccount: PhoneAccountHandle?,
|
||||||
|
// request: android.telecom.ConnectionRequest
|
||||||
|
// ): Connection {
|
||||||
|
// Log.d(TAG, "Creating incoming connection for ${request.address}, account: $connectionManagerPhoneAccount")
|
||||||
|
// val connection = object : Connection() {
|
||||||
|
// override fun onAnswer() {
|
||||||
|
// Log.d(TAG, "Connection answered")
|
||||||
|
// setActive()
|
||||||
|
// }
|
||||||
|
|
||||||
|
// override fun onDisconnect() {
|
||||||
|
// Log.d(TAG, "Connection disconnected")
|
||||||
|
// setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
|
||||||
|
// destroy()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED)
|
||||||
|
// connection.setRinging()
|
||||||
|
// return connection
|
||||||
|
// }
|
||||||
|
// }
|
@ -0,0 +1,55 @@
|
|||||||
|
package com.icing.dialer.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.telecom.TelecomManager
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.Manifest
|
||||||
|
|
||||||
|
object CallService {
|
||||||
|
private val TAG = "CallService"
|
||||||
|
|
||||||
|
fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
||||||
|
val uri = Uri.parse("tel:$phoneNumber")
|
||||||
|
|
||||||
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
telecomManager.placeCall(uri, Bundle())
|
||||||
|
Log.d(TAG, "Initiated call to $phoneNumber")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "CALL_PHONE permission not granted")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error making GSM call: ${e.message}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hangUpCall(context: Context): Boolean {
|
||||||
|
return try {
|
||||||
|
if (MyInCallService.currentCall != null) {
|
||||||
|
MyInCallService.currentCall?.disconnect()
|
||||||
|
Log.d(TAG, "Disconnected active call via MyInCallService")
|
||||||
|
true
|
||||||
|
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||||
|
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
||||||
|
telecomManager.endCall()
|
||||||
|
Log.d(TAG, "Ended call via TelecomManager (no active call in MyInCallService)")
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "No active call and hangup not supported below Android P")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error hanging up call: ${e.message}", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,156 @@
|
|||||||
|
package com.icing.dialer.services
|
||||||
|
|
||||||
|
import android.app.KeyguardManager
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.telecom.Call
|
||||||
|
import android.telecom.InCallService
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.icing.dialer.activities.MainActivity
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
|
||||||
|
class MyInCallService : InCallService() {
|
||||||
|
companion object {
|
||||||
|
var channel: MethodChannel? = null
|
||||||
|
var currentCall: Call? = null
|
||||||
|
private const val TAG = "MyInCallService"
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel"
|
||||||
|
private const val NOTIFICATION_ID = 1
|
||||||
|
var wasPhoneLocked: Boolean = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private val callCallback = object : Call.Callback() {
|
||||||
|
override fun onStateChanged(call: Call, state: Int) {
|
||||||
|
super.onStateChanged(call, state)
|
||||||
|
val stateStr = when (state) {
|
||||||
|
Call.STATE_DIALING -> "dialing"
|
||||||
|
Call.STATE_ACTIVE -> "active"
|
||||||
|
Call.STATE_DISCONNECTED -> "disconnected"
|
||||||
|
Call.STATE_DISCONNECTING -> "disconnecting"
|
||||||
|
Call.STATE_RINGING -> "ringing"
|
||||||
|
else -> "unknown"
|
||||||
|
}
|
||||||
|
Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}")
|
||||||
|
channel?.invokeMethod("callStateChanged", mapOf(
|
||||||
|
"callId" to call.details.handle.toString(),
|
||||||
|
"state" to stateStr,
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
if (state == Call.STATE_RINGING) {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
wasPhoneLocked = keyguardManager.isKeyguardLocked
|
||||||
|
Log.d(TAG, "Phone locked at ringing: $wasPhoneLocked")
|
||||||
|
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
|
||||||
|
} else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
|
||||||
|
Log.d(TAG, "Call ended: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
|
||||||
|
channel?.invokeMethod("callEnded", mapOf(
|
||||||
|
"callId" to call.details.handle.toString(),
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
currentCall = null
|
||||||
|
cancelNotification()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallAdded(call: Call) {
|
||||||
|
super.onCallAdded(call)
|
||||||
|
currentCall = call
|
||||||
|
val stateStr = when (call.state) {
|
||||||
|
Call.STATE_DIALING -> "dialing"
|
||||||
|
Call.STATE_ACTIVE -> "active"
|
||||||
|
Call.STATE_RINGING -> "ringing"
|
||||||
|
else -> "dialing"
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr")
|
||||||
|
channel?.invokeMethod("callAdded", mapOf(
|
||||||
|
"callId" to call.details.handle.toString(),
|
||||||
|
"state" to stateStr
|
||||||
|
))
|
||||||
|
if (stateStr == "ringing") {
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
wasPhoneLocked = keyguardManager.isKeyguardLocked
|
||||||
|
Log.d(TAG, "Phone locked at call added: $wasPhoneLocked")
|
||||||
|
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
|
||||||
|
}
|
||||||
|
call.registerCallback(callCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallRemoved(call: Call) {
|
||||||
|
super.onCallRemoved(call)
|
||||||
|
Log.d(TAG, "Call removed: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
|
||||||
|
call.unregisterCallback(callCallback)
|
||||||
|
channel?.invokeMethod("callRemoved", mapOf(
|
||||||
|
"callId" to call.details.handle.toString(),
|
||||||
|
"wasPhoneLocked" to wasPhoneLocked
|
||||||
|
))
|
||||||
|
currentCall = null
|
||||||
|
cancelNotification()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) {
|
||||||
|
super.onCallAudioStateChanged(state)
|
||||||
|
Log.d(TAG, "Audio state changed: route=${state.route}")
|
||||||
|
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showIncomingCallScreen(phoneNumber: String) {
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||||
|
putExtra("phoneNumber", phoneNumber)
|
||||||
|
putExtra("isIncomingCall", true)
|
||||||
|
putExtra("showIncomingCallScreen", true)
|
||||||
|
putExtra("wasPhoneLocked", wasPhoneLocked)
|
||||||
|
}
|
||||||
|
|
||||||
|
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
|
||||||
|
if (keyguardManager.isKeyguardLocked) {
|
||||||
|
startActivity(intent)
|
||||||
|
Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber")
|
||||||
|
} else {
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
"Incoming Calls",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Notifications for incoming calls"
|
||||||
|
enableVibration(true)
|
||||||
|
setShowBadge(true)
|
||||||
|
}
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
val pendingIntent = PendingIntent.getActivity(
|
||||||
|
this, 0, intent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
.setSmallIcon(android.R.drawable.ic_dialog_alert)
|
||||||
|
.setContentTitle("Incoming Call")
|
||||||
|
.setContentText("Call from $phoneNumber")
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setFullScreenIntent(pendingIntent, true)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
startActivity(intent)
|
||||||
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
Log.d(TAG, "Launched MainActivity with notification for unlocked screen, phoneNumber: $phoneNumber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun cancelNotification() {
|
||||||
|
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
|
Log.d(TAG, "Notification canceled")
|
||||||
|
}
|
||||||
|
}
|
BIN
dialer/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Executable file
After Width: | Height: | Size: 2.4 KiB |
BIN
dialer/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Executable file
After Width: | Height: | Size: 1.5 KiB |
BIN
dialer/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Executable file
After Width: | Height: | Size: 3.0 KiB |
BIN
dialer/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Executable file
After Width: | Height: | Size: 4.8 KiB |
BIN
dialer/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Executable file
After Width: | Height: | Size: 5.6 KiB |
@ -1,6 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||||
|
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||||
|
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||||
<!-- The INTERNET permission is required for development. Specifically,
|
<!-- The INTERNET permission is required for development. Specifically,
|
||||||
the Flutter tool needs it to communicate with the running application
|
the Flutter tool needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
@ -16,3 +16,4 @@ subprojects {
|
|||||||
tasks.register("clean", Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
|||||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||||
|
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
@ -18,8 +18,8 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.1.0" apply false
|
id "com.android.application" version "8.3.2" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "1.8.22" apply false
|
id "org.jetbrains.kotlin.android" version "2.0.20" apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
include ":app"
|
include ":app"
|
10
dialer/build.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
|
||||||
|
IMG=git.gmoker.com/icing/flutter:main
|
||||||
|
|
||||||
|
if [ "$1" == '-s' ]; then
|
||||||
|
OPT+=(--dart-define=STEALTH=true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -x
|
||||||
|
docker run --rm -v "$PWD:/app/" "$IMG" build apk "${OPT[@]}"
|
3
dialer/devtools_options.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
|
extensions:
|
348
dialer/lib/features/call/call_page.dart
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/services/call_service.dart';
|
||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
|
||||||
|
class CallPage extends StatefulWidget {
|
||||||
|
final String displayName;
|
||||||
|
final String phoneNumber;
|
||||||
|
final Uint8List? thumbnail;
|
||||||
|
|
||||||
|
const CallPage({
|
||||||
|
super.key,
|
||||||
|
required this.displayName,
|
||||||
|
required this.phoneNumber,
|
||||||
|
this.thumbnail,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_CallPageState createState() => _CallPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CallPageState extends State<CallPage> {
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final CallService _callService = CallService();
|
||||||
|
bool isMuted = false;
|
||||||
|
bool isSpeakerOn = false;
|
||||||
|
bool isKeypadVisible = false;
|
||||||
|
bool icingProtocolOk = true;
|
||||||
|
String _typedDigits = "";
|
||||||
|
|
||||||
|
void _addDigit(String digit) {
|
||||||
|
setState(() {
|
||||||
|
_typedDigits += digit;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleMute() {
|
||||||
|
setState(() {
|
||||||
|
isMuted = !isMuted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleSpeaker() {
|
||||||
|
setState(() {
|
||||||
|
isSpeakerOn = !isSpeakerOn;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleKeypad() {
|
||||||
|
setState(() {
|
||||||
|
isKeypadVisible = !isKeypadVisible;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleIcingProtocol() {
|
||||||
|
setState(() {
|
||||||
|
icingProtocolOk = !icingProtocolOk;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _hangUp() async {
|
||||||
|
try {
|
||||||
|
final result = await _callService.hangUpCall(context);
|
||||||
|
print('CallPage: Hang up result: $result');
|
||||||
|
if (result["status"] == "ended" && mounted && Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("CallPage: Error hanging up: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Error hanging up: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final double avatarRadius = isKeypadVisible ? 45.0 : 45.0;
|
||||||
|
final double nameFontSize = isKeypadVisible ? 24.0 : 24.0;
|
||||||
|
final double statusFontSize = isKeypadVisible ? 16.0 : 16.0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 35),
|
||||||
|
ObfuscatedAvatar(
|
||||||
|
imageBytes: widget.thumbnail,
|
||||||
|
radius: avatarRadius,
|
||||||
|
backgroundColor:
|
||||||
|
generateColorFromName(widget.displayName),
|
||||||
|
fallbackInitial: widget.displayName,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icingProtocolOk ? Icons.lock : Icons.lock_open,
|
||||||
|
color: icingProtocolOk ? Colors.green : Colors.red,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: icingProtocolOk ? Colors.green : Colors.red,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_obfuscateService.obfuscateData(widget.displayName),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: nameFontSize,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.phoneNumber,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: statusFontSize, color: Colors.white70),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Calling...',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: statusFontSize, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (isKeypadVisible) ...[
|
||||||
|
const Spacer(flex: 2),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_typedDigits,
|
||||||
|
maxLines: 1,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
onPressed: _toggleKeypad,
|
||||||
|
icon:
|
||||||
|
const Icon(Icons.close, color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.35,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: GridView.count(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisCount: 3,
|
||||||
|
childAspectRatio: 1.3,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
crossAxisSpacing: 8,
|
||||||
|
children: List.generate(12, (index) {
|
||||||
|
String label;
|
||||||
|
if (index < 9) {
|
||||||
|
label = '${index + 1}';
|
||||||
|
} else if (index == 9) {
|
||||||
|
label = '*';
|
||||||
|
} else if (index == 10) {
|
||||||
|
label = '0';
|
||||||
|
} else {
|
||||||
|
label = '#';
|
||||||
|
}
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _addDigit(label),
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 32, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(flex: 1),
|
||||||
|
] else ...[
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _toggleMute,
|
||||||
|
icon: Icon(
|
||||||
|
isMuted ? Icons.mic_off : Icons.mic,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
isMuted ? 'Unmute' : 'Mute',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _toggleKeypad,
|
||||||
|
icon: const Icon(Icons.dialpad,
|
||||||
|
color: Colors.white, size: 32),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Keypad',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: _toggleSpeaker,
|
||||||
|
icon: Icon(
|
||||||
|
isSpeakerOn
|
||||||
|
? Icons.volume_up
|
||||||
|
: Icons.volume_off,
|
||||||
|
color: isSpeakerOn
|
||||||
|
? Colors.amber
|
||||||
|
: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Speaker',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.person_add,
|
||||||
|
color: Colors.white, size: 32),
|
||||||
|
),
|
||||||
|
const Text('Add Contact',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(Icons.sim_card,
|
||||||
|
color: Colors.white, size: 32),
|
||||||
|
),
|
||||||
|
const Text('Change SIM',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(flex: 3),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: _hangUp,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.call_end,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
181
dialer/lib/features/call/incoming_call_page.dart
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:dialer/services/call_service.dart';
|
||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import 'package:dialer/features/call/call_page.dart';
|
||||||
|
|
||||||
|
class IncomingCallPage extends StatefulWidget {
|
||||||
|
final String displayName;
|
||||||
|
final String phoneNumber;
|
||||||
|
final Uint8List? thumbnail;
|
||||||
|
|
||||||
|
const IncomingCallPage({
|
||||||
|
super.key,
|
||||||
|
required this.displayName,
|
||||||
|
required this.phoneNumber,
|
||||||
|
this.thumbnail,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_IncomingCallPageState createState() => _IncomingCallPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IncomingCallPageState extends State<IncomingCallPage> {
|
||||||
|
static const MethodChannel _channel = MethodChannel('call_service');
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final CallService _callService = CallService();
|
||||||
|
bool icingProtocolOk = true;
|
||||||
|
|
||||||
|
void _toggleIcingProtocol() {
|
||||||
|
setState(() {
|
||||||
|
icingProtocolOk = !icingProtocolOk;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _answerCall() async {
|
||||||
|
try {
|
||||||
|
final result = await _channel.invokeMethod('answerCall');
|
||||||
|
print('IncomingCallPage: Answer call result: $result');
|
||||||
|
if (result["status"] == "answered") {
|
||||||
|
Navigator.pushReplacement(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => CallPage(
|
||||||
|
displayName: widget.displayName,
|
||||||
|
phoneNumber: widget.phoneNumber,
|
||||||
|
thumbnail: widget.thumbnail,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("IncomingCallPage: Error answering call: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Error answering call: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _declineCall() async {
|
||||||
|
try {
|
||||||
|
await _callService.hangUpCall(context);
|
||||||
|
} catch (e) {
|
||||||
|
print("IncomingCallPage: Error declining call: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Error declining call: $e")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const double avatarRadius = 45.0;
|
||||||
|
const double nameFontSize = 24.0;
|
||||||
|
const double statusFontSize = 16.0;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
body: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 35),
|
||||||
|
ObfuscatedAvatar(
|
||||||
|
imageBytes: widget.thumbnail,
|
||||||
|
radius: avatarRadius,
|
||||||
|
backgroundColor: generateColorFromName(widget.displayName),
|
||||||
|
fallbackInitial: widget.displayName,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
icingProtocolOk ? Icons.lock : Icons.lock_open,
|
||||||
|
color: icingProtocolOk ? Colors.green : Colors.red,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: icingProtocolOk ? Colors.green : Colors.red,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_obfuscateService.obfuscateData(widget.displayName),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: nameFontSize,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.phoneNumber,
|
||||||
|
style: const TextStyle(fontSize: statusFontSize, color: Colors.white70),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'Incoming Call...',
|
||||||
|
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _declineCall,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.red,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.call_end,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _answerCall,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.green,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.call,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
335
dialer/lib/features/composition/composition.dart
Normal file
@ -0,0 +1,335 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import '../../services/contact_service.dart';
|
||||||
|
import '../../services/obfuscate_service.dart';
|
||||||
|
import '../../services/call_service.dart';
|
||||||
|
|
||||||
|
class CompositionPage extends StatefulWidget {
|
||||||
|
const CompositionPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_CompositionPageState createState() => _CompositionPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompositionPageState extends State<CompositionPage> {
|
||||||
|
String dialedNumber = "";
|
||||||
|
List<Contact> _allContacts = [];
|
||||||
|
List<Contact> _filteredContacts = [];
|
||||||
|
final ContactService _contactService = ContactService();
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final CallService _callService = CallService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchContacts() async {
|
||||||
|
_allContacts = await _contactService.fetchContacts();
|
||||||
|
_filteredContacts = _allContacts;
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _filterContacts() {
|
||||||
|
setState(() {
|
||||||
|
_filteredContacts = _allContacts.where((contact) {
|
||||||
|
bool phoneMatch = contact.phones.any((phone) {
|
||||||
|
final rawPhoneNumber = phone.number;
|
||||||
|
final strippedPhoneNumber = rawPhoneNumber.replaceAll(RegExp(r'\D'), '');
|
||||||
|
final strippedDialedNumber = dialedNumber.replaceAll(RegExp(r'\D'), '');
|
||||||
|
return rawPhoneNumber.contains(dialedNumber) ||
|
||||||
|
strippedPhoneNumber.contains(strippedDialedNumber);
|
||||||
|
});
|
||||||
|
final nameMatch = contact.displayName
|
||||||
|
.toLowerCase()
|
||||||
|
.contains(dialedNumber.toLowerCase());
|
||||||
|
return phoneMatch || nameMatch;
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onNumberPress(String number) {
|
||||||
|
setState(() {
|
||||||
|
dialedNumber += number;
|
||||||
|
_filterContacts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPlusPress() {
|
||||||
|
setState(() {
|
||||||
|
dialedNumber += '+';
|
||||||
|
_filterContacts();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onDeletePress() {
|
||||||
|
setState(() {
|
||||||
|
if (dialedNumber.isNotEmpty) {
|
||||||
|
dialedNumber = dialedNumber.substring(0, dialedNumber.length - 1);
|
||||||
|
_filterContacts();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onClearPress() {
|
||||||
|
setState(() {
|
||||||
|
dialedNumber = "";
|
||||||
|
_filteredContacts = _allContacts;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _makeCall(String phoneNumber) async {
|
||||||
|
try {
|
||||||
|
await _callService.makeGsmCall(context, phoneNumber: phoneNumber);
|
||||||
|
setState(() {
|
||||||
|
dialedNumber = phoneNumber;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Error making call: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to make call: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchSms(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not send SMS to $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addContact() async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
final newContact = Contact()
|
||||||
|
..phones = [Phone(dialedNumber.isNotEmpty ? dialedNumber : '')];
|
||||||
|
final updatedContact = await FlutterContacts.openExternalInsert(newContact);
|
||||||
|
if (updatedContact != null) {
|
||||||
|
_fetchContacts();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Contact added successfully!')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 42.0, left: 16.0, right: 16.0, bottom: 16.0),
|
||||||
|
color: Colors.black,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
children: [
|
||||||
|
..._filteredContacts.map((contact) {
|
||||||
|
final phoneNumber = contact.phones.isNotEmpty
|
||||||
|
? contact.phones.first.number
|
||||||
|
: 'No phone number';
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
_obfuscateService.obfuscateData(phoneNumber),
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.phone, color: Colors.green[300], size: 20),
|
||||||
|
onPressed: () => _makeCall(phoneNumber),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.message, color: Colors.blue[300], size: 20),
|
||||||
|
onPressed: () => _launchSms(phoneNumber),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {},
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Add a contact',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: Icon(Icons.add, color: Colors.grey[600]),
|
||||||
|
onTap: _addContact,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
dialedNumber,
|
||||||
|
style: const TextStyle(fontSize: 24, color: Colors.white),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _onDeletePress,
|
||||||
|
onLongPress: _onClearPress,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(8.0),
|
||||||
|
child: Icon(Icons.backspace, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('1', Colors.white),
|
||||||
|
_buildDialButton('2', Colors.white),
|
||||||
|
_buildDialButton('3', Colors.white),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('4', Colors.white),
|
||||||
|
_buildDialButton('5', Colors.white),
|
||||||
|
_buildDialButton('6', Colors.white),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('7', Colors.white),
|
||||||
|
_buildDialButton('8', Colors.white),
|
||||||
|
_buildDialButton('9', Colors.white),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)),
|
||||||
|
_buildDialButtonWithPlus('0'),
|
||||||
|
_buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 20.0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Center(
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: dialedNumber.isNotEmpty ? () => _makeCall(dialedNumber) : null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green[700],
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.phone, color: Colors.white, size: 30),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 40.0,
|
||||||
|
left: 16.0,
|
||||||
|
child: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDialButton(String number, Color textColor) {
|
||||||
|
return ElevatedButton(
|
||||||
|
onPressed: () => _onNumberPress(number),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: TextStyle(fontSize: 24, color: textColor),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDialButtonWithPlus(String number) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onLongPress: _onPlusPress,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => _onNumberPress(number),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
number,
|
||||||
|
style: const TextStyle(fontSize: 24, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 8,
|
||||||
|
child: Text(
|
||||||
|
'+',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -15,13 +15,12 @@ class _ContactPageState extends State<ContactPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final contactState = ContactState.of(context);
|
final contactState = ContactState.of(context);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Contacts'),
|
|
||||||
),
|
|
||||||
body: contactState.loading
|
body: contactState.loading
|
||||||
? const LoadingIndicatorWidget()
|
? const LoadingIndicatorWidget()
|
||||||
// : ContactListWidget(contacts: contactState.contacts),
|
: AlphabetScrollPage(
|
||||||
: AlphabetScrollPage(contacts: contactState.contacts, scrollOffset: contactState.scrollOffset),
|
scrollOffset: contactState.scrollOffset,
|
||||||
|
contacts: contactState.contacts, // Use all contacts here
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
141
dialer/lib/features/contacts/contact_state.dart
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import '../../services/contact_service.dart';
|
||||||
|
|
||||||
|
class ContactState extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ContactState({super.key, required this.child});
|
||||||
|
|
||||||
|
static _ContactStateState of(BuildContext context) {
|
||||||
|
return context
|
||||||
|
.dependOnInheritedWidgetOfExactType<_InheritedContactState>()!
|
||||||
|
.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ContactStateState createState() => _ContactStateState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactStateState extends State<ContactState> {
|
||||||
|
final ContactService _contactService = ContactService();
|
||||||
|
List<Contact> _allContacts = [];
|
||||||
|
List<Contact> _favoriteContacts = [];
|
||||||
|
bool _loading = true;
|
||||||
|
double _scrollOffset = 0.0;
|
||||||
|
Contact? _selfContact = Contact();
|
||||||
|
|
||||||
|
// Getters for all contacts and favorites
|
||||||
|
List<Contact> get contacts => _allContacts;
|
||||||
|
List<Contact> get favoriteContacts => _favoriteContacts;
|
||||||
|
bool get loading => _loading;
|
||||||
|
double get scrollOffset => _scrollOffset;
|
||||||
|
Contact? get selfContact => _selfContact;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
fetchContacts(); // Fetch all contacts by default
|
||||||
|
FlutterContacts.addListener(_onContactChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onContactChange() => fetchContacts();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
FlutterContacts.removeListener(_onContactChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all contacts
|
||||||
|
Future<void> fetchContacts() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
List<Contact> contacts = await _contactService.fetchContacts();
|
||||||
|
_processContacts(contacts);
|
||||||
|
} finally {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch only favorite contacts
|
||||||
|
Future<void> fetchFavoriteContacts() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
List<Contact> contacts = await _contactService.fetchFavoriteContacts();
|
||||||
|
setState(() => _favoriteContacts = contacts);
|
||||||
|
} finally {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _processContacts(List<Contact> contacts) {
|
||||||
|
_selfContact = contacts.firstWhere(
|
||||||
|
(contact) => contact.displayName.toLowerCase() == "user",
|
||||||
|
orElse: () => Contact(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (_selfContact!.phones.isEmpty) {
|
||||||
|
debugPrint("Self contact has no phone numbers");
|
||||||
|
_selfContact = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts = contacts.where((contact) => contact.phones.isNotEmpty).toList();
|
||||||
|
contacts.sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_allContacts = contacts;
|
||||||
|
_favoriteContacts =
|
||||||
|
contacts.where((contact) => contact.isStarred).toList();
|
||||||
|
_selfContact = _selfContact;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addNewContact(Contact contact) async {
|
||||||
|
await _contactService.addNewContact(contact);
|
||||||
|
await fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setScrollOffset(double offset) {
|
||||||
|
setState(() {
|
||||||
|
_scrollOffset = offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bool doesContactExist(Contact contact) {
|
||||||
|
// Example: consider it "existing" if there's a matching phone number
|
||||||
|
for (final existing in _allContacts) {
|
||||||
|
if (existing.toVCard() == contact.toVCard()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// for (final phone in existing.phones) {
|
||||||
|
// for (final newPhone in contact.phones) {
|
||||||
|
// // Simple exact match; you can do more advanced logic
|
||||||
|
// if (phone.normalizedNumber == newPhone.normalizedNumber) {
|
||||||
|
// return true;
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// } We might switch to finer and smarter logic later, ex: remove trailing spaces, capitals
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _InheritedContactState(
|
||||||
|
data: this,
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _InheritedContactState extends InheritedWidget {
|
||||||
|
final _ContactStateState data;
|
||||||
|
|
||||||
|
const _InheritedContactState({required this.data, required super.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(_InheritedContactState oldWidget) => true;
|
||||||
|
}
|
63
dialer/lib/features/contacts/widgets/add_contact_button.dart
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import 'package:dialer/widgets/qr_scanner.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
|
||||||
|
class AddContactButton extends StatelessWidget {
|
||||||
|
const AddContactButton({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.add, color: Colors.blue),
|
||||||
|
onPressed: () {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true, // Allows dismissal by tapping outside
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop(); // close dialog
|
||||||
|
|
||||||
|
// Go to QR Scanner
|
||||||
|
final vCardString = await Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const QRCodeScannerScreen(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (vCardString != null && vCardString is String) {
|
||||||
|
await FlutterContacts.openExternalInsert(Contact
|
||||||
|
.fromVCard(vCardString));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Scan QR code",
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
// Create a blank contact entry
|
||||||
|
await FlutterContacts.openExternalInsert();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Create new contact",
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
217
dialer/lib/features/contacts/widgets/alphabet_scroll_page.dart
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import '../contact_state.dart';
|
||||||
|
import 'add_contact_button.dart';
|
||||||
|
import 'contact_modal.dart';
|
||||||
|
import 'share_own_qr.dart';
|
||||||
|
|
||||||
|
class AlphabetScrollPage extends StatefulWidget {
|
||||||
|
final double scrollOffset;
|
||||||
|
final List<Contact> contacts;
|
||||||
|
|
||||||
|
const AlphabetScrollPage({
|
||||||
|
super.key,
|
||||||
|
required this.scrollOffset,
|
||||||
|
required this.contacts,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_AlphabetScrollPageState createState() => _AlphabetScrollPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AlphabetScrollPageState extends State<AlphabetScrollPage> {
|
||||||
|
late ScrollController _scrollController;
|
||||||
|
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController = ScrollController(initialScrollOffset: widget.scrollOffset);
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
contactState.setScrollOffset(_scrollController.offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshContacts() async {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
try {
|
||||||
|
await contactState.fetchContacts();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error refreshing contacts: $e');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to refresh contacts')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavorite(Contact contact) async {
|
||||||
|
try {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
Contact? fullContact = await FlutterContacts.getContact(contact.id,
|
||||||
|
withProperties: true,
|
||||||
|
withAccounts: true,
|
||||||
|
withPhoto: true,
|
||||||
|
withThumbnail: true);
|
||||||
|
|
||||||
|
if (fullContact != null) {
|
||||||
|
fullContact.isStarred = !fullContact.isStarred;
|
||||||
|
await FlutterContacts.updateContact(fullContact);
|
||||||
|
}
|
||||||
|
await _refreshContacts();
|
||||||
|
} else {
|
||||||
|
print("Could not fetch contact details");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error updating favorite status: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to update contact favorite status')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contacts = widget.contacts;
|
||||||
|
final selfContact = ContactState.of(context).selfContact;
|
||||||
|
|
||||||
|
Map<String, List<Contact>> alphabetizedContacts = {};
|
||||||
|
for (var contact in contacts) {
|
||||||
|
String firstLetter = contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '#';
|
||||||
|
if (!alphabetizedContacts.containsKey(firstLetter)) {
|
||||||
|
alphabetizedContacts[firstLetter] = [];
|
||||||
|
}
|
||||||
|
alphabetizedContacts[firstLetter]!.add(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> alphabetKeys = alphabetizedContacts.keys.toList()..sort();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
// Top buttons row
|
||||||
|
Container(
|
||||||
|
color: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
AddContactButton(),
|
||||||
|
QRCodeButton(contacts: contacts, selfContact: selfContact),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Contact List
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
itemCount: alphabetKeys.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
String letter = alphabetKeys[index];
|
||||||
|
List<Contact> contactsForLetter = alphabetizedContacts[letter]!;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Alphabet Letter Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8.0, horizontal: 16.0),
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Contact Entries
|
||||||
|
...contactsForLetter.map((contact) {
|
||||||
|
String phoneNumber = contact.phones.isNotEmpty
|
||||||
|
? _obfuscateService.obfuscateData(contact.phones.first.number)
|
||||||
|
: 'No phone number';
|
||||||
|
Color avatarColor =
|
||||||
|
generateColorFromName(contact.displayName);
|
||||||
|
return ListTile(
|
||||||
|
leading: ObfuscatedAvatar(
|
||||||
|
imageBytes: contact.thumbnail,
|
||||||
|
radius: 25,
|
||||||
|
backgroundColor: avatarColor,
|
||||||
|
fallbackInitial: contact.displayName,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
phoneNumber,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) {
|
||||||
|
return ContactModal(
|
||||||
|
contact: contact,
|
||||||
|
onEdit: () async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
final updatedContact =
|
||||||
|
await FlutterContacts.openExternalEdit(
|
||||||
|
contact.id);
|
||||||
|
if (updatedContact != null) {
|
||||||
|
await _refreshContacts();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${contact.displayName} updated successfully!'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Edit canceled or failed.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleFavorite: () {
|
||||||
|
_toggleFavorite(contact);
|
||||||
|
},
|
||||||
|
isFavorite: contact.isStarred,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
353
dialer/lib/features/contacts/widgets/contact_modal.dart
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import '../../../services/block_service.dart';
|
||||||
|
import '../../../services/contact_service.dart';
|
||||||
|
import '../../../services/call_service.dart';
|
||||||
|
|
||||||
|
class ContactModal extends StatefulWidget {
|
||||||
|
final Contact contact;
|
||||||
|
final Function onEdit;
|
||||||
|
final Function onToggleFavorite;
|
||||||
|
final bool isFavorite;
|
||||||
|
|
||||||
|
const ContactModal({
|
||||||
|
super.key,
|
||||||
|
required this.contact,
|
||||||
|
required this.onEdit,
|
||||||
|
required this.onToggleFavorite,
|
||||||
|
required this.isFavorite,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ContactModalState createState() => _ContactModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ContactModalState extends State<ContactModal> {
|
||||||
|
late String phoneNumber;
|
||||||
|
bool isBlocked = false;
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final CallService _callService = CallService();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
phoneNumber = widget.contact.phones.isNotEmpty
|
||||||
|
? widget.contact.phones.first.number
|
||||||
|
: 'No phone number';
|
||||||
|
_checkIfBlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkIfBlocked() async {
|
||||||
|
if (phoneNumber != 'No phone number') {
|
||||||
|
bool blocked = await BlockService().isNumberBlocked(phoneNumber);
|
||||||
|
setState(() {
|
||||||
|
isBlocked = blocked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleBlockState() async {
|
||||||
|
if (phoneNumber == 'No phone number') {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No phone number to block or unblock')),
|
||||||
|
);
|
||||||
|
} else if (isBlocked) {
|
||||||
|
await BlockService().unblockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$phoneNumber has been unblocked')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await BlockService().blockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$phoneNumber has been blocked')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phoneNumber != 'No phone number') {
|
||||||
|
_checkIfBlocked();
|
||||||
|
}
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchPhoneDialer(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'tel', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchSms(String phoneNumber) async {
|
||||||
|
final uri = Uri(scheme: 'sms', path: phoneNumber);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch SMS to $phoneNumber');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchEmail(String email) async {
|
||||||
|
final uri = Uri(scheme: 'mailto', path: email);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri);
|
||||||
|
} else {
|
||||||
|
debugPrint('Could not launch email to $email');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _deleteContact() async {
|
||||||
|
final bool shouldDelete = await showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Contact'),
|
||||||
|
content: Text(
|
||||||
|
'Are you sure you want to delete ${_obfuscateService.obfuscateData(widget.contact.displayName)}?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldDelete) {
|
||||||
|
try {
|
||||||
|
// Delete the contact
|
||||||
|
await FlutterContacts.deleteContact(widget.contact);
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
// Handle errors and show a failure message
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Failed to delete ${widget.contact.displayName}: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _shareContactAsQRCode() {
|
||||||
|
// Use the ContactService to show the QR code for the contact's vCard
|
||||||
|
ContactService().showContactQRCodeDialog(context, widget.contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
String email = widget.contact.emails.isNotEmpty
|
||||||
|
? _obfuscateService.obfuscateData(widget.contact.emails.first.address)
|
||||||
|
: 'No email';
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
child: FractionallySizedBox(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[900],
|
||||||
|
borderRadius:
|
||||||
|
const BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Modal Handle and Three-Dot Menu
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
width: 50,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topRight,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10, right: 10),
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert,
|
||||||
|
color: Colors.white),
|
||||||
|
onSelected: (String choice) {
|
||||||
|
if (choice == 'delete') {
|
||||||
|
_deleteContact();
|
||||||
|
} else if (choice == 'share') {
|
||||||
|
_shareContactAsQRCode();
|
||||||
|
}
|
||||||
|
// Handle other choices if needed
|
||||||
|
},
|
||||||
|
itemBuilder: (BuildContext context) {
|
||||||
|
return [
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'show_associated_contacts',
|
||||||
|
child: Text('Show associated contacts'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text('Delete'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'share',
|
||||||
|
child: Text('Share (via QR code)'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'create_shortcut',
|
||||||
|
child:
|
||||||
|
Text('Create shortcut (to home screen)'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'set_ringtone',
|
||||||
|
child: Text('Set ringtone'),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// Contact Profile
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ObfuscatedAvatar(
|
||||||
|
imageBytes: widget.contact.thumbnail,
|
||||||
|
radius: 50,
|
||||||
|
backgroundColor:
|
||||||
|
generateColorFromName(widget.contact.displayName),
|
||||||
|
fallbackInitial: widget.contact.displayName,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
_obfuscateService
|
||||||
|
.obfuscateData(widget.contact.displayName),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.grey),
|
||||||
|
// Contact Actions
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.phone, color: Colors.green),
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(phoneNumber),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
if (widget.contact.phones.isNotEmpty) {
|
||||||
|
await _callService.makeGsmCall(context,
|
||||||
|
phoneNumber: phoneNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.message, color: Colors.blue),
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(phoneNumber),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.contact.phones.isNotEmpty) {
|
||||||
|
_launchSms(phoneNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.email, color: Colors.orange),
|
||||||
|
title: Text(
|
||||||
|
email,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
if (widget.contact.emails.isNotEmpty) {
|
||||||
|
_launchEmail(email);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.grey),
|
||||||
|
// Favorite, Edit, and Block/Unblock Buttons
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Favorite button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
widget.onToggleFavorite();
|
||||||
|
},
|
||||||
|
icon: Icon(widget.isFavorite
|
||||||
|
? Icons.star
|
||||||
|
: Icons.star_border),
|
||||||
|
label: Text(
|
||||||
|
widget.isFavorite ? 'Unfavorite' : 'Favorite'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Edit button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () => widget.onEdit(),
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: const Text('Edit Contact'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Block/Unblock button
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: _toggleBlockState,
|
||||||
|
icon: Icon(
|
||||||
|
isBlocked ? Icons.block : Icons.block_flipped),
|
||||||
|
label: Text(isBlocked ? 'Unblock' : 'Block'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
36
dialer/lib/features/contacts/widgets/share_own_qr.dart
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/contact.dart';
|
||||||
|
import 'package:dialer/services/contact_service.dart';
|
||||||
|
|
||||||
|
class QRCodeButton extends StatelessWidget {
|
||||||
|
final List<Contact> contacts;
|
||||||
|
final Contact? selfContact;
|
||||||
|
|
||||||
|
const QRCodeButton({super.key, required this.contacts, this.selfContact});
|
||||||
|
|
||||||
|
Contact? getSelfContact() {
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugPrint("Checking for self contact");
|
||||||
|
}
|
||||||
|
for (var contact in contacts) {
|
||||||
|
if (contact.groups.any((group) => group.name.toLowerCase() == "user")) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: Icon(Icons.qr_code, color: selfContact != null ? Colors.blue : Colors.grey),
|
||||||
|
onPressed: selfContact != null
|
||||||
|
? () {
|
||||||
|
// Use the ContactService to show the QR code
|
||||||
|
ContactService().showContactQRCodeDialog(context, selfContact!);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
32
dialer/lib/features/favorites/favorites_page.dart
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/features/contacts/widgets/alphabet_scroll_page.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/widgets/loading_indicator.dart';
|
||||||
|
|
||||||
|
class FavoritesPage extends StatefulWidget {
|
||||||
|
const FavoritesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_FavoritesPageState createState() => _FavoritesPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FavoritesPageState extends State<FavoritesPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
return Scaffold(
|
||||||
|
body: contactState.loading
|
||||||
|
? const LoadingIndicatorWidget()
|
||||||
|
: AlphabetScrollPage(
|
||||||
|
scrollOffset: contactState.scrollOffset,
|
||||||
|
contacts:
|
||||||
|
contactState.favoriteContacts, // Use only favorites here
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
662
dialer/lib/features/history/history_page.dart
Normal file
@ -0,0 +1,662 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:dialer/widgets/color_darkener.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/widgets/username_color_generator.dart';
|
||||||
|
import '../../services/block_service.dart';
|
||||||
|
import '../contacts/widgets/contact_modal.dart';
|
||||||
|
import '../../services/call_service.dart';
|
||||||
|
|
||||||
|
class History {
|
||||||
|
final Contact contact;
|
||||||
|
final DateTime date;
|
||||||
|
final String callType; // 'incoming' or 'outgoing'
|
||||||
|
final String callStatus; // 'missed' or 'answered'
|
||||||
|
final int attempts;
|
||||||
|
|
||||||
|
History(
|
||||||
|
this.contact,
|
||||||
|
this.date,
|
||||||
|
this.callType,
|
||||||
|
this.callStatus,
|
||||||
|
this.attempts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class HistoryPage extends StatefulWidget {
|
||||||
|
const HistoryPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_HistoryPageState createState() => _HistoryPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryPageState extends State<HistoryPage>
|
||||||
|
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||||
|
List<History> histories = [];
|
||||||
|
bool loading = true;
|
||||||
|
int? _expandedIndex;
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final CallService _callService = CallService();
|
||||||
|
|
||||||
|
// Create a MethodChannel instance.
|
||||||
|
static const MethodChannel _channel = MethodChannel('com.example.calllog');
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => true; // Preserve state when switching pages
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
if (loading && histories.isEmpty) {
|
||||||
|
_buildHistories();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshContacts() async {
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
try {
|
||||||
|
await contactState.fetchContacts();
|
||||||
|
} catch (e) {
|
||||||
|
print('Error refreshing contacts: $e');
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Failed to refresh contacts')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavorite(Contact contact) async {
|
||||||
|
try {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
Contact? fullContact = await FlutterContacts.getContact(contact.id,
|
||||||
|
withProperties: true,
|
||||||
|
withAccounts: true,
|
||||||
|
withPhoto: true,
|
||||||
|
withThumbnail: true);
|
||||||
|
|
||||||
|
if (fullContact != null) {
|
||||||
|
fullContact.isStarred = !fullContact.isStarred;
|
||||||
|
await FlutterContacts.updateContact(fullContact);
|
||||||
|
}
|
||||||
|
await _refreshContacts();
|
||||||
|
} else {
|
||||||
|
print("Could not fetch contact details");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error updating favorite status: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to update favorite status')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Remove all non-digit characters for simple matching.
|
||||||
|
String sanitizeNumber(String number) {
|
||||||
|
return number.replaceAll(RegExp(r'\D'), '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper: Find a contact from our list by matching phone numbers.
|
||||||
|
Contact? findContactForNumber(String number, List<Contact> contacts) {
|
||||||
|
final sanitized = sanitizeNumber(number);
|
||||||
|
for (var contact in contacts) {
|
||||||
|
for (var phone in contact.phones) {
|
||||||
|
if (sanitizeNumber(phone.number) == sanitized) {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request permission for reading call logs.
|
||||||
|
Future<bool> _requestCallLogPermission() async {
|
||||||
|
var status = await Permission.phone.status;
|
||||||
|
if (!status.isGranted) {
|
||||||
|
status = await Permission.phone.request();
|
||||||
|
}
|
||||||
|
return status.isGranted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build histories from the native call log using the method channel.
|
||||||
|
Future<void> _buildHistories() async {
|
||||||
|
// Request permission.
|
||||||
|
bool hasPermission = await _requestCallLogPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Call log permission not granted')));
|
||||||
|
setState(() {
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve call logs from native code.
|
||||||
|
List<dynamic> nativeLogs = [];
|
||||||
|
try {
|
||||||
|
nativeLogs = await _channel.invokeMethod('getCallLogs');
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
print("Error fetching call logs: ${e.message}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure contacts are loaded.
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
if (contactState.loading) {
|
||||||
|
await Future.doWhile(() async {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
return contactState.loading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
List<Contact> contacts = contactState.contacts;
|
||||||
|
|
||||||
|
List<History> callHistories = [];
|
||||||
|
// Process each log entry with intermittent yields to avoid freezing.
|
||||||
|
for (int i = 0; i < nativeLogs.length; i++) {
|
||||||
|
final entry = nativeLogs[i];
|
||||||
|
final String number = entry['number'] ?? '';
|
||||||
|
if (number.isEmpty) continue;
|
||||||
|
|
||||||
|
// Convert timestamp to DateTime.
|
||||||
|
DateTime callDate =
|
||||||
|
DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0);
|
||||||
|
|
||||||
|
int typeInt = entry['type'] ?? 0;
|
||||||
|
int duration = entry['duration'] ?? 0;
|
||||||
|
String callType;
|
||||||
|
String callStatus;
|
||||||
|
|
||||||
|
// Map integer values to call type/status.
|
||||||
|
// Commonly: 1 = incoming, 2 = outgoing, 3 = missed.
|
||||||
|
switch (typeInt) {
|
||||||
|
case 1:
|
||||||
|
callType = "incoming";
|
||||||
|
callStatus = (duration == 0) ? "missed" : "answered";
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
callType = "outgoing";
|
||||||
|
callStatus = "answered";
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
callType = "incoming";
|
||||||
|
callStatus = "missed";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
callType = "unknown";
|
||||||
|
callStatus = "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find a matching contact.
|
||||||
|
Contact? matchedContact = findContactForNumber(number, contacts);
|
||||||
|
if (matchedContact == null) {
|
||||||
|
// Create a dummy contact if not found.
|
||||||
|
matchedContact = Contact(
|
||||||
|
id: "dummy-$number",
|
||||||
|
displayName: number,
|
||||||
|
phones: [Phone(number)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
callHistories
|
||||||
|
.add(History(matchedContact, callDate, callType, callStatus, 1));
|
||||||
|
// Yield every 10 iterations to avoid blocking the UI.
|
||||||
|
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort histories by most recent.
|
||||||
|
callHistories.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
histories = callHistories;
|
||||||
|
loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List _buildGroupedList(List<History> historyList) {
|
||||||
|
historyList.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final yesterday = today.subtract(const Duration(days: 1));
|
||||||
|
|
||||||
|
List<History> todayHistories = [];
|
||||||
|
List<History> yesterdayHistories = [];
|
||||||
|
List<History> olderHistories = [];
|
||||||
|
|
||||||
|
for (var history in historyList) {
|
||||||
|
final callDate =
|
||||||
|
DateTime(history.date.year, history.date.month, history.date.day);
|
||||||
|
if (callDate == today) {
|
||||||
|
todayHistories.add(history);
|
||||||
|
} else if (callDate == yesterday) {
|
||||||
|
yesterdayHistories.add(history);
|
||||||
|
} else {
|
||||||
|
olderHistories.add(history);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final items = <dynamic>[];
|
||||||
|
if (todayHistories.isNotEmpty) {
|
||||||
|
items.add('Today');
|
||||||
|
items.addAll(todayHistories);
|
||||||
|
}
|
||||||
|
if (yesterdayHistories.isNotEmpty) {
|
||||||
|
items.add('Yesterday');
|
||||||
|
items.addAll(yesterdayHistories);
|
||||||
|
}
|
||||||
|
if (olderHistories.isNotEmpty) {
|
||||||
|
items.add('Older');
|
||||||
|
items.addAll(olderHistories);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an icon reflecting call type and status.
|
||||||
|
Icon _getCallIcon(History history) {
|
||||||
|
IconData iconData;
|
||||||
|
Color iconColor;
|
||||||
|
if (history.callType == 'incoming') {
|
||||||
|
if (history.callStatus == 'missed') {
|
||||||
|
iconData = Icons.call_missed;
|
||||||
|
iconColor = Colors.red;
|
||||||
|
} else {
|
||||||
|
iconData = Icons.call_received;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
}
|
||||||
|
} else if (history.callType == 'outgoing') {
|
||||||
|
iconData = Icons.call_made;
|
||||||
|
iconColor = Colors.green;
|
||||||
|
} else {
|
||||||
|
iconData = Icons.phone;
|
||||||
|
iconColor = Colors.white;
|
||||||
|
}
|
||||||
|
return Icon(iconData, color: iconColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context); // required due to AutomaticKeepAliveClientMixin
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
|
||||||
|
if (loading || contactState.loading) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (histories.isEmpty) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No call history available.',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<History> missedCalls =
|
||||||
|
histories.where((h) => h.callStatus == 'missed').toList();
|
||||||
|
|
||||||
|
final allItems = _buildGroupedList(histories);
|
||||||
|
final missedItems = _buildGroupedList(missedCalls);
|
||||||
|
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 2,
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(kToolbarHeight),
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'All Calls'),
|
||||||
|
Tab(text: 'Missed Calls'),
|
||||||
|
],
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: TabBarView(
|
||||||
|
children: [
|
||||||
|
_buildListView(allItems),
|
||||||
|
_buildListView(missedItems),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildListView(List items) {
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
if (item is String) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
color: Colors.grey[900],
|
||||||
|
child: Text(
|
||||||
|
item,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (item is History) {
|
||||||
|
final history = item;
|
||||||
|
final contact = history.contact;
|
||||||
|
final isExpanded = _expandedIndex == index;
|
||||||
|
Color avatarColor = generateColorFromName(contact.displayName);
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) {
|
||||||
|
return ContactModal(
|
||||||
|
contact: contact,
|
||||||
|
onEdit: () async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
final updatedContact =
|
||||||
|
await FlutterContacts.openExternalEdit(
|
||||||
|
contact.id);
|
||||||
|
if (updatedContact != null) {
|
||||||
|
await _refreshContacts();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${contact.displayName} updated successfully!'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Edit canceled or failed.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleFavorite: () {
|
||||||
|
_toggleFavorite(contact);
|
||||||
|
},
|
||||||
|
isFavorite: contact.isStarred,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ObfuscatedAvatar(
|
||||||
|
imageBytes: contact.thumbnail,
|
||||||
|
radius: 25,
|
||||||
|
backgroundColor: avatarColor,
|
||||||
|
fallbackInitial: contact.displayName,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
DateFormat('MMM dd, hh:mm a').format(history.date),
|
||||||
|
style: const TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_getCallIcon(history),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'${history.attempts}x',
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.phone, color: Colors.green),
|
||||||
|
onPressed: () async {
|
||||||
|
if (contact.phones.isNotEmpty) {
|
||||||
|
_callService.makeGsmCall(context, phoneNumber: contact.phones.first.number);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Contact has no phone number')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_expandedIndex = isExpanded ? null : index;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (isExpanded)
|
||||||
|
Container(
|
||||||
|
color: Colors.grey[850],
|
||||||
|
child: FutureBuilder<bool>(
|
||||||
|
future: BlockService().isNumberBlocked(
|
||||||
|
contact.phones.isNotEmpty
|
||||||
|
? contact.phones.first.number
|
||||||
|
: ''),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
final isBlocked = snapshot.data ?? false;
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
if (history.contact.phones.isNotEmpty) {
|
||||||
|
final Uri smsUri = Uri(
|
||||||
|
scheme: 'sms',
|
||||||
|
path: history.contact.phones.first.number);
|
||||||
|
if (await canLaunchUrl(smsUri)) {
|
||||||
|
await launchUrl(smsUri);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Could not send message')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Contact has no phone number')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
icon:
|
||||||
|
const Icon(Icons.message, color: Colors.white),
|
||||||
|
label: const Text('Message',
|
||||||
|
style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) =>
|
||||||
|
CallDetailsPage(history: history),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.info, color: Colors.white),
|
||||||
|
label: const Text('Details',
|
||||||
|
style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final phoneNumber = contact.phones.isNotEmpty
|
||||||
|
? contact.phones.first.number
|
||||||
|
: null;
|
||||||
|
if (phoneNumber == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content:
|
||||||
|
Text('Contact has no phone number')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isBlocked) {
|
||||||
|
await BlockService().unblockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$phoneNumber unblocked')),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await BlockService().blockNumber(phoneNumber);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$phoneNumber blocked')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
icon: Icon(
|
||||||
|
isBlocked ? Icons.lock_open : Icons.block,
|
||||||
|
color: Colors.white),
|
||||||
|
label: Text(isBlocked ? 'Unblock' : 'Block',
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallDetailsPage extends StatelessWidget {
|
||||||
|
final History history;
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
|
||||||
|
CallDetailsPage({super.key, required this.history});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final contact = history.contact;
|
||||||
|
final contactBg = generateColorFromName(contact.displayName);
|
||||||
|
final contactLetter = darken(contactBg);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Call Details'),
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Display Contact Name and Thumbnail.
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
(contact.thumbnail != null && contact.thumbnail!.isNotEmpty)
|
||||||
|
? ObfuscatedAvatar(
|
||||||
|
imageBytes: contact.thumbnail,
|
||||||
|
radius: 30,
|
||||||
|
backgroundColor: contactBg,
|
||||||
|
fallbackInitial: contact.displayName,
|
||||||
|
)
|
||||||
|
: CircleAvatar(
|
||||||
|
backgroundColor:
|
||||||
|
generateColorFromName(contact.displayName),
|
||||||
|
radius: 30,
|
||||||
|
child: Text(
|
||||||
|
contact.displayName.isNotEmpty
|
||||||
|
? contact.displayName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(color: contactLetter),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Display call details.
|
||||||
|
DetailRow(
|
||||||
|
label: 'Call Type:',
|
||||||
|
value: history.callType,
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Call Status:',
|
||||||
|
value: history.callStatus,
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Date:',
|
||||||
|
value: DateFormat('MMM dd, yyyy - hh:mm a').format(history.date),
|
||||||
|
),
|
||||||
|
DetailRow(
|
||||||
|
label: 'Attempts:',
|
||||||
|
value: '${history.attempts}',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (contact.phones.isNotEmpty)
|
||||||
|
DetailRow(
|
||||||
|
label: 'Number:',
|
||||||
|
value: _obfuscateService
|
||||||
|
.obfuscateData(contact.phones.first.number),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DetailRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
const DetailRow({Key? key, required this.label, required this.value})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
342
dialer/lib/features/home/home_page.dart
Normal file
@ -0,0 +1,342 @@
|
|||||||
|
import 'package:dialer/services/obfuscate_service.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/features/contacts/contact_page.dart';
|
||||||
|
import 'package:dialer/features/favorites/favorites_page.dart';
|
||||||
|
import 'package:dialer/features/history/history_page.dart';
|
||||||
|
import 'package:dialer/features/composition/composition.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:dialer/features/settings/settings.dart';
|
||||||
|
import '../../services/contact_service.dart';
|
||||||
|
import 'package:dialer/features/voicemail/voicemail_page.dart';
|
||||||
|
import '../contacts/widgets/contact_modal.dart';
|
||||||
|
|
||||||
|
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
List<Contact> _allContacts = [];
|
||||||
|
List<Contact> _contactSuggestions = [];
|
||||||
|
final ContactService _contactService = ContactService();
|
||||||
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
late SearchController _searchBarController;
|
||||||
|
String _rawSearchInput = '';
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this, initialIndex: 2);
|
||||||
|
_tabController.addListener(_handleTabIndex);
|
||||||
|
_searchBarController = SearchController();
|
||||||
|
_searchBarController.addListener(() {
|
||||||
|
if (_searchController.text != _searchBarController.text) {
|
||||||
|
_rawSearchInput = _searchBarController.text;
|
||||||
|
_searchController.text = _rawSearchInput;
|
||||||
|
_onSearchChanged(_searchBarController.text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
_fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _fetchContacts() async {
|
||||||
|
_allContacts = await _contactService.fetchContacts();
|
||||||
|
_contactSuggestions = List.from(_allContacts);
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSearch() {
|
||||||
|
_searchController.clear();
|
||||||
|
_searchBarController.clear();
|
||||||
|
_rawSearchInput = '';
|
||||||
|
_onSearchChanged('');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSearchChanged(String query) {
|
||||||
|
setState(() {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
_contactSuggestions = List.from(_allContacts);
|
||||||
|
} else {
|
||||||
|
final normalizedQuery = _normalizeString(query.toLowerCase());
|
||||||
|
_contactSuggestions = _allContacts.where((contact) {
|
||||||
|
final normalizedName = _normalizeString(contact.displayName.toLowerCase());
|
||||||
|
return normalizedName.contains(normalizedQuery);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeString(String input) {
|
||||||
|
const accentMap = {
|
||||||
|
'àáâãäå': 'a',
|
||||||
|
'èéêë': 'e',
|
||||||
|
'ìíîï': 'i',
|
||||||
|
'òóôõö': 'o',
|
||||||
|
'ùúûü': 'u',
|
||||||
|
'ç': 'c',
|
||||||
|
'ñ': 'n',
|
||||||
|
};
|
||||||
|
String normalized = input;
|
||||||
|
accentMap.forEach((accents, base) {
|
||||||
|
for (var accent in accents.split('')) {
|
||||||
|
normalized = normalized.replaceAll(accent, base);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
_searchBarController.dispose();
|
||||||
|
_tabController.removeListener(_handleTabIndex);
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTabIndex() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggleFavorite(Contact contact) async {
|
||||||
|
try {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
Contact? fullContact = await FlutterContacts.getContact(
|
||||||
|
contact.id,
|
||||||
|
withProperties: true,
|
||||||
|
withAccounts: true,
|
||||||
|
withPhoto: true,
|
||||||
|
withThumbnail: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (fullContact != null) {
|
||||||
|
fullContact.isStarred = !fullContact.isStarred;
|
||||||
|
await FlutterContacts.updateContact(fullContact);
|
||||||
|
_fetchContacts();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print("Could not fetch contact details");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print("Error updating favorite status: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to update contact favorite status')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 24.0,
|
||||||
|
bottom: 10.0,
|
||||||
|
left: 16.0,
|
||||||
|
right: 16.0,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color.fromARGB(255, 30, 30, 30),
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
border: Border.all(color: Colors.grey.shade800, width: 1),
|
||||||
|
),
|
||||||
|
child: SearchAnchor(
|
||||||
|
searchController: _searchBarController,
|
||||||
|
builder: (BuildContext context, SearchController controller) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
controller.openView();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color.fromARGB(255, 30, 30, 30),
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
border: Border.all(color: Colors.grey.shade800, width: 1),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.search, color: Colors.grey, size: 24.0),
|
||||||
|
const SizedBox(width: 8.0),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_rawSearchInput.isEmpty
|
||||||
|
? 'Search contacts'
|
||||||
|
: _rawSearchInput,
|
||||||
|
style: const TextStyle(color: Colors.grey, fontSize: 16.0),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_rawSearchInput.isNotEmpty)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _clearSearch,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: Colors.grey,
|
||||||
|
size: 24.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
viewOnChanged: (query) {
|
||||||
|
|
||||||
|
if (_searchBarController.text != query) {
|
||||||
|
_rawSearchInput = query;
|
||||||
|
_searchBarController.text = query;
|
||||||
|
_searchController.text = query;
|
||||||
|
}
|
||||||
|
_onSearchChanged(query);
|
||||||
|
},
|
||||||
|
suggestionsBuilder: (BuildContext context, SearchController controller) {
|
||||||
|
return _contactSuggestions.map((contact) {
|
||||||
|
return ListTile(
|
||||||
|
key: ValueKey(contact.id),
|
||||||
|
title: Text(
|
||||||
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
controller.closeView(contact.displayName);
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (context) {
|
||||||
|
return ContactModal(
|
||||||
|
contact: contact,
|
||||||
|
onEdit: () async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
final updatedContact = await FlutterContacts
|
||||||
|
.openExternalEdit(contact.id);
|
||||||
|
if (updatedContact != null) {
|
||||||
|
_fetchContacts();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'${contact.displayName} updated successfully!'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Edit canceled or failed.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onToggleFavorite: () => _toggleFavorite(contact),
|
||||||
|
isFavorite: contact.isStarred,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||||
|
itemBuilder: (BuildContext context) => [
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'settings',
|
||||||
|
child: Text('Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onSelected: (String value) {
|
||||||
|
if (value == 'settings') {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsPage()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: const [
|
||||||
|
FavoritesPage(),
|
||||||
|
HistoryPage(),
|
||||||
|
ContactPage(),
|
||||||
|
VoicemailPage(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
right: 20,
|
||||||
|
bottom: 20,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const CompositionPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(45),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.dialpad, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: Container(
|
||||||
|
color: Colors.black,
|
||||||
|
child: TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 0
|
||||||
|
? Icons.star
|
||||||
|
: Icons.star_border)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 1
|
||||||
|
? Icons.access_time_filled
|
||||||
|
: Icons.access_time_outlined)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 2
|
||||||
|
? Icons.contacts
|
||||||
|
: Icons.contacts_outlined)),
|
||||||
|
Tab(
|
||||||
|
icon: Icon(_tabController.index == 3
|
||||||
|
? Icons.voicemail
|
||||||
|
: Icons.voicemail_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
labelColor: Colors.white,
|
||||||
|
unselectedLabelColor: const Color.fromARGB(255, 158, 158, 158),
|
||||||
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
|
indicatorColor: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyHomePage extends StatefulWidget {
|
||||||
|
const MyHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_MyHomePageState createState() => _MyHomePageState();
|
||||||
|
}
|
167
dialer/lib/features/settings/blocked/settings_blocked.dart
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BlockedNumbersPage extends StatefulWidget {
|
||||||
|
const BlockedNumbersPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_BlockedNumbersPageState createState() => _BlockedNumbersPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BlockedNumbersPageState extends State<BlockedNumbersPage> {
|
||||||
|
bool _blockUnknownNumbers = false; // Toggle for blocking unknown numbers
|
||||||
|
List<String> _blockedNumbers = []; // List of blocked numbers
|
||||||
|
final TextEditingController _numberController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadPreferences(); // Load data on initialization
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load preferences from local storage
|
||||||
|
Future<void> _loadPreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
setState(() {
|
||||||
|
_blockUnknownNumbers = prefs.getBool('blockUnknownNumbers') ?? false;
|
||||||
|
_blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save preferences to local storage
|
||||||
|
Future<void> _savePreferences() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setBool('blockUnknownNumbers', _blockUnknownNumbers);
|
||||||
|
await prefs.setStringList('blockedNumbers', _blockedNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Blocked Numbers'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Block Unknown Numbers',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
value: _blockUnknownNumbers,
|
||||||
|
onChanged: (bool value) {
|
||||||
|
setState(() {
|
||||||
|
_blockUnknownNumbers = value;
|
||||||
|
_savePreferences(); // Save the state to local storage
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Blocked Numbers',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
subtitle: _blockedNumbers.isEmpty
|
||||||
|
? const Text(
|
||||||
|
'No blocked numbers',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
..._blockedNumbers.map(
|
||||||
|
(number) => ListTile(
|
||||||
|
title: Text(
|
||||||
|
number,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: IconButton(
|
||||||
|
icon: const Icon(Icons.delete, color: Colors.red),
|
||||||
|
onPressed: () => _unblockNumber(number),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.grey),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Block a Number',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.add, color: Colors.white),
|
||||||
|
onTap: () => _showBlockNumberDialog(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to block a number
|
||||||
|
void _blockNumber(String number) {
|
||||||
|
if (number.isNotEmpty && !_blockedNumbers.contains(number)) {
|
||||||
|
setState(() {
|
||||||
|
_blockedNumbers.add(number);
|
||||||
|
_savePreferences(); // Save the updated list
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$number has been blocked')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to unblock a number
|
||||||
|
void _unblockNumber(String number) {
|
||||||
|
setState(() {
|
||||||
|
_blockedNumbers.remove(number);
|
||||||
|
_savePreferences(); // Save the updated list
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('$number has been unblocked')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog for blocking a new number
|
||||||
|
void _showBlockNumberDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
title: const Text('Block a Number', style: TextStyle(color: Colors.white)),
|
||||||
|
content: TextField(
|
||||||
|
controller: _numberController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Enter number',
|
||||||
|
hintStyle: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Cancel', style: TextStyle(color: Colors.white)),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
_blockNumber(_numberController.text);
|
||||||
|
_numberController.clear();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('Block', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_numberController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
92
dialer/lib/features/settings/call/settingsCall.dart
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SettingsCallPage extends StatefulWidget {
|
||||||
|
const SettingsCallPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_SettingsCallPageState createState() => _SettingsCallPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsCallPageState extends State<SettingsCallPage> {
|
||||||
|
bool _enableVoicemail = true;
|
||||||
|
bool _enableCallRecording = false;
|
||||||
|
String _ringtone = 'Default';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Calling settings'),
|
||||||
|
),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Enable Voicemail', style: TextStyle(color: Colors.white)),
|
||||||
|
value: _enableVoicemail,
|
||||||
|
onChanged: (bool value) {
|
||||||
|
setState(() {
|
||||||
|
_enableVoicemail = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('Enable call Recording', style: TextStyle(color: Colors.white)),
|
||||||
|
value: _enableCallRecording,
|
||||||
|
onChanged: (bool value) {
|
||||||
|
setState(() {
|
||||||
|
_enableCallRecording = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Ringtone', style: TextStyle(color: Colors.white)),
|
||||||
|
subtitle: Text(_ringtone, style: const TextStyle(color: Colors.grey)),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
||||||
|
onTap: () {
|
||||||
|
_selectRingtone(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectRingtone(BuildContext context) {
|
||||||
|
showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return SimpleDialog(
|
||||||
|
title: const Text('Select Ringtone'),
|
||||||
|
children: <Widget>[
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context, 'Default');
|
||||||
|
},
|
||||||
|
child: const Text('Default'),
|
||||||
|
),
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context, 'Classic');
|
||||||
|
},
|
||||||
|
child: const Text('Classic'),
|
||||||
|
),
|
||||||
|
SimpleDialogOption(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context, 'Beep');
|
||||||
|
},
|
||||||
|
child: const Text('Beep'),
|
||||||
|
),
|
||||||
|
// Add more ringtone options
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).then((value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_ringtone = value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
145
dialer/lib/features/settings/cryptography/key_management.dart
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/services/cryptography/asymmetric_crypto_service.dart';
|
||||||
|
|
||||||
|
class ManageKeysPage extends StatefulWidget {
|
||||||
|
const ManageKeysPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_ManageKeysPageState createState() => _ManageKeysPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManageKeysPageState extends State<ManageKeysPage> {
|
||||||
|
final AsymmetricCryptoService _cryptoService = AsymmetricCryptoService();
|
||||||
|
List<Map<String, dynamic>> _keys = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadKeys() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
List<Map<String, dynamic>> keys = await _cryptoService.getAllKeys();
|
||||||
|
setState(() {
|
||||||
|
_keys = keys;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error loading keys: $e')));
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _generateKey() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await _cryptoService.generateKeyPair();
|
||||||
|
await _loadKeys();
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(const SnackBar(content: Text('Key generated successfully')));
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error generating key: $e')));
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteKey(String alias) async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await _cryptoService.deleteKeyPair(alias);
|
||||||
|
await _loadKeys();
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(const SnackBar(content: Text('Key deleted successfully')));
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error deleting key: $e')));
|
||||||
|
} finally {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _viewPublicKey(String alias) async {
|
||||||
|
try {
|
||||||
|
final publicKey = await _cryptoService.getPublicKey(alias);
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => AlertDialog(
|
||||||
|
title: const Text('Public Key'),
|
||||||
|
content: SingleChildScrollView(child: Text(publicKey)),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Close'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
ScaffoldMessenger.of(context)
|
||||||
|
.showSnackBar(SnackBar(content: Text('Error retrieving public key: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Manage Keys'),
|
||||||
|
),
|
||||||
|
body: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _keys.isEmpty
|
||||||
|
? const Center(child: Text('No keys found'))
|
||||||
|
: ListView.builder(
|
||||||
|
itemCount: _keys.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final keyData = _keys[index];
|
||||||
|
return ListTile(
|
||||||
|
title: Text(keyData['label'] ?? 'No label'),
|
||||||
|
subtitle: Text(keyData['alias'] ?? ''),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.visibility),
|
||||||
|
tooltip: 'View Public Key',
|
||||||
|
onPressed: () => _viewPublicKey(keyData['alias']),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete),
|
||||||
|
tooltip: 'Delete Key',
|
||||||
|
onPressed: () => _deleteKey(keyData['alias']),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
onPressed: _generateKey,
|
||||||
|
child: const Icon(Icons.add),
|
||||||
|
tooltip: 'Generate New Key',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
69
dialer/lib/features/settings/settings.dart
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// settings.dart
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/features/settings/call/settingsCall.dart';
|
||||||
|
// import 'package:dialer/features/settings/cryptography/';
|
||||||
|
import 'package:dialer/features/settings/blocked/settings_blocked.dart';
|
||||||
|
import 'cryptography/key_management.dart';
|
||||||
|
|
||||||
|
class SettingsPage extends StatelessWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
void _navigateToSettings(BuildContext context, String setting) {
|
||||||
|
switch (setting) {
|
||||||
|
case 'Calling settings':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const SettingsCallPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Key management':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => ManageKeysPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'Blocked numbers':
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (context) => const BlockedNumbersPage()),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
// Add more cases for other settings pages
|
||||||
|
default:
|
||||||
|
// Handle default or unknown settings
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final settingsOptions = [
|
||||||
|
'Calling settings',
|
||||||
|
'Key management',
|
||||||
|
'Blocked numbers'
|
||||||
|
];
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
),
|
||||||
|
body: ListView.builder(
|
||||||
|
itemCount: settingsOptions.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
settingsOptions[index],
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: const Icon(Icons.arrow_forward_ios, color: Colors.white),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToSettings(context, settingsOptions[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
209
dialer/lib/features/voicemail/voicemail_page.dart
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
|
||||||
|
class VoicemailPage extends StatefulWidget {
|
||||||
|
const VoicemailPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VoicemailPage> createState() => _VoicemailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoicemailPageState extends State<VoicemailPage> {
|
||||||
|
bool _expanded = false;
|
||||||
|
bool _isPlaying = false;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
Duration _position = Duration.zero;
|
||||||
|
late AudioPlayer _audioPlayer;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_audioPlayer = AudioPlayer();
|
||||||
|
_audioPlayer.onDurationChanged.listen((Duration d) {
|
||||||
|
setState(() => _duration = d);
|
||||||
|
});
|
||||||
|
_audioPlayer.onPositionChanged.listen((Duration p) {
|
||||||
|
setState(() => _position = p);
|
||||||
|
});
|
||||||
|
_audioPlayer.onPlayerComplete.listen((event) {
|
||||||
|
setState(() {
|
||||||
|
_isPlaying = false;
|
||||||
|
_position = Duration.zero;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _togglePlayPause() async {
|
||||||
|
if (_isPlaying) {
|
||||||
|
await _audioPlayer.pause();
|
||||||
|
} else {
|
||||||
|
await _audioPlayer.play(UrlSource('voicemail.mp3'));
|
||||||
|
}
|
||||||
|
setState(() => _isPlaying = !_isPlaying);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_audioPlayer.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
// appBar: AppBar(
|
||||||
|
// // title: const Text('Voicemail'),
|
||||||
|
// backgroundColor: Colors.black,
|
||||||
|
// ),
|
||||||
|
body: ListView(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_expanded = !_expanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color.fromARGB(255, 30, 30, 30),
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _expanded
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
child: Text(
|
||||||
|
"JD",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
fontSize: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
Text(
|
||||||
|
'John Doe',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Wed 3:00 PM - 1:20 min',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
_isPlaying ? Icons.pause : Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: _togglePlayPause,
|
||||||
|
),
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: Slider(
|
||||||
|
min: 0,
|
||||||
|
max: _duration.inSeconds.toDouble(),
|
||||||
|
value: _position.inSeconds.toDouble(),
|
||||||
|
onChanged: (value) async {
|
||||||
|
final newPos = Duration(seconds: value.toInt());
|
||||||
|
await _audioPlayer.seek(newPos);
|
||||||
|
},
|
||||||
|
activeColor: Colors.blue,
|
||||||
|
inactiveColor: Colors.grey,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.call, color: Colors.green),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Call', style: TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.message, color: Colors.blue),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Text', style: TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.block, color: Colors.red),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Block', style: TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
Icon(Icons.share, color: Colors.white),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Share', style: TextStyle(color: Colors.white)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(
|
||||||
|
radius: 28,
|
||||||
|
backgroundColor: Colors.amber,
|
||||||
|
child: Text(
|
||||||
|
"JD",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.deepOrange,
|
||||||
|
fontSize: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: const [
|
||||||
|
Text(
|
||||||
|
'John Doe',
|
||||||
|
style: TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Wed 3:00 PM - 1:20 min',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
1
dialer/lib/globals.dart
Normal file
@ -0,0 +1 @@
|
|||||||
|
bool isStealthMode = false;
|
66
dialer/lib/main.dart
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import 'package:dialer/features/home/home_page.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:dialer/features/contacts/contact_state.dart';
|
||||||
|
import 'package:dialer/services/call_service.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'globals.dart' as globals;
|
||||||
|
import 'package:dialer/services/cryptography/asymmetric_crypto_service.dart';
|
||||||
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
void main() async {
|
||||||
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const stealthFlag = String.fromEnvironment('STEALTH', defaultValue: 'false');
|
||||||
|
globals.isStealthMode = stealthFlag.toLowerCase() == 'true';
|
||||||
|
|
||||||
|
final AsymmetricCryptoService cryptoService = AsymmetricCryptoService();
|
||||||
|
await cryptoService.initializeDefaultKeyPair();
|
||||||
|
|
||||||
|
// Request permissions before running the app
|
||||||
|
await _requestPermissions();
|
||||||
|
|
||||||
|
CallService();
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
Provider<AsymmetricCryptoService>(
|
||||||
|
create: (_) => cryptoService,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: Dialer(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _requestPermissions() async {
|
||||||
|
Map<Permission, PermissionStatus> statuses = await [
|
||||||
|
Permission.phone,
|
||||||
|
Permission.contacts,
|
||||||
|
Permission.microphone,
|
||||||
|
].request();
|
||||||
|
if (statuses.values.every((status) => status.isGranted)) {
|
||||||
|
print("All required permissions granted");
|
||||||
|
const channel = MethodChannel('call_service');
|
||||||
|
await channel.invokeMethod('permissionsGranted');
|
||||||
|
} else {
|
||||||
|
print("Permissions denied: ${statuses.entries.where((e) => !e.value.isGranted).map((e) => e.key).join(', ')}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Dialer extends StatelessWidget {
|
||||||
|
const Dialer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ContactState(
|
||||||
|
child: MaterialApp(
|
||||||
|
navigatorKey: CallService.navigatorKey,
|
||||||
|
theme: ThemeData(
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
home: SafeArea(child: MyHomePage()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
52
dialer/lib/services/block_service.dart
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BlockService {
|
||||||
|
static final BlockService _instance = BlockService._internal();
|
||||||
|
|
||||||
|
factory BlockService() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlockService._internal();
|
||||||
|
|
||||||
|
// Function to add a number to the blocked list
|
||||||
|
Future<void> blockNumber(String number) async {
|
||||||
|
if (number.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
|
||||||
|
if (!blockedNumbers.contains(number)) {
|
||||||
|
blockedNumbers.add(number);
|
||||||
|
await prefs.setStringList('blockedNumbers', blockedNumbers);
|
||||||
|
print('$number has been blocked');
|
||||||
|
} else {
|
||||||
|
print('$number is already blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to remove a number from the blocked list
|
||||||
|
Future<void> unblockNumber(String number) async {
|
||||||
|
if (number.isEmpty) return;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
|
||||||
|
if (blockedNumbers.contains(number)) {
|
||||||
|
blockedNumbers.remove(number);
|
||||||
|
await prefs.setStringList('blockedNumbers', blockedNumbers);
|
||||||
|
print('$number has been unblocked');
|
||||||
|
} else {
|
||||||
|
print('$number is not blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a number is blocked
|
||||||
|
Future<bool> isNumberBlocked(String number) async {
|
||||||
|
if (number.isEmpty) return false;
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
List<String> blockedNumbers = prefs.getStringList('blockedNumbers') ?? [];
|
||||||
|
return blockedNumbers.contains(number);
|
||||||
|
}
|
||||||
|
}
|
207
dialer/lib/services/call_service.dart
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import '../features/call/call_page.dart';
|
||||||
|
import '../features/call/incoming_call_page.dart';
|
||||||
|
|
||||||
|
class CallService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('call_service');
|
||||||
|
static String? currentPhoneNumber;
|
||||||
|
static bool _isCallPageVisible = false;
|
||||||
|
static Map<String, dynamic>? _pendingCall;
|
||||||
|
static bool wasPhoneLocked = false;
|
||||||
|
|
||||||
|
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
|
|
||||||
|
CallService() {
|
||||||
|
_channel.setMethodCallHandler((call) async {
|
||||||
|
print('CallService: Handling method call: ${call.method}');
|
||||||
|
switch (call.method) {
|
||||||
|
case "callAdded":
|
||||||
|
final phoneNumber = call.arguments["callId"] as String;
|
||||||
|
final state = call.arguments["state"] as String;
|
||||||
|
currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
|
||||||
|
print('CallService: Call added, number: $currentPhoneNumber, state: $state');
|
||||||
|
if (state == "ringing") {
|
||||||
|
_handleIncomingCall(phoneNumber);
|
||||||
|
} else {
|
||||||
|
_navigateToCallPage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "callStateChanged":
|
||||||
|
final state = call.arguments["state"] as String;
|
||||||
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
|
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
||||||
|
if (state == "disconnected" || state == "disconnecting") {
|
||||||
|
_closeCallPage();
|
||||||
|
if (wasPhoneLocked) {
|
||||||
|
_channel.invokeMethod("callEndedFromFlutter");
|
||||||
|
}
|
||||||
|
} else if (state == "active" || state == "dialing") {
|
||||||
|
_navigateToCallPage();
|
||||||
|
} else if (state == "ringing") {
|
||||||
|
final phoneNumber = call.arguments["callId"] as String;
|
||||||
|
_handleIncomingCall(phoneNumber.replaceFirst('tel:', ''));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "callEnded":
|
||||||
|
case "callRemoved":
|
||||||
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
|
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
|
||||||
|
_closeCallPage();
|
||||||
|
if (wasPhoneLocked) {
|
||||||
|
_channel.invokeMethod("callEndedFromFlutter");
|
||||||
|
}
|
||||||
|
currentPhoneNumber = null;
|
||||||
|
break;
|
||||||
|
case "incomingCallFromNotification":
|
||||||
|
final phoneNumber = call.arguments["phoneNumber"] as String;
|
||||||
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
|
currentPhoneNumber = phoneNumber;
|
||||||
|
print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked');
|
||||||
|
_handleIncomingCall(phoneNumber);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleIncomingCall(String phoneNumber) {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
print('CallService: Context is null, queuing incoming call: $phoneNumber');
|
||||||
|
_pendingCall = {"phoneNumber": phoneNumber};
|
||||||
|
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
||||||
|
} else {
|
||||||
|
_navigateToIncomingCallPage(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkPendingCall() {
|
||||||
|
if (_pendingCall != null) {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context != null) {
|
||||||
|
print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}');
|
||||||
|
currentPhoneNumber = _pendingCall!["phoneNumber"];
|
||||||
|
_navigateToIncomingCallPage(context);
|
||||||
|
_pendingCall = null;
|
||||||
|
} else {
|
||||||
|
print('CallService: Context still null, retrying...');
|
||||||
|
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToCallPage() {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
print('CallService: Cannot navigate to CallPage, context is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_isCallPageVisible) {
|
||||||
|
print('CallService: CallPage already visible, skipping navigation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('CallService: Navigating to CallPage');
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: '/call'),
|
||||||
|
builder: (context) => CallPage(
|
||||||
|
displayName: currentPhoneNumber!,
|
||||||
|
phoneNumber: currentPhoneNumber!,
|
||||||
|
thumbnail: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((_) {
|
||||||
|
_isCallPageVisible = false;
|
||||||
|
print('CallService: CallPage popped, _isCallPageVisible set to false');
|
||||||
|
});
|
||||||
|
_isCallPageVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToIncomingCallPage(BuildContext context) {
|
||||||
|
if (_isCallPageVisible) {
|
||||||
|
print('CallService: IncomingCallPage already visible, skipping navigation');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('CallService: Navigating to IncomingCallPage');
|
||||||
|
Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
settings: const RouteSettings(name: '/incoming_call'),
|
||||||
|
builder: (context) => IncomingCallPage(
|
||||||
|
displayName: currentPhoneNumber!,
|
||||||
|
phoneNumber: currentPhoneNumber!,
|
||||||
|
thumbnail: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).then((_) {
|
||||||
|
_isCallPageVisible = false;
|
||||||
|
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
|
||||||
|
});
|
||||||
|
_isCallPageVisible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _closeCallPage() {
|
||||||
|
final context = navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
print('CallService: Cannot close page, context is null');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible');
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
print('CallService: Popping call page');
|
||||||
|
Navigator.pop(context);
|
||||||
|
_isCallPageVisible = false;
|
||||||
|
} else {
|
||||||
|
print('CallService: No page to pop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> makeGsmCall(
|
||||||
|
BuildContext context, {
|
||||||
|
required String phoneNumber,
|
||||||
|
String? displayName,
|
||||||
|
Uint8List? thumbnail,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
currentPhoneNumber = phoneNumber;
|
||||||
|
print('CallService: Making GSM call to $phoneNumber');
|
||||||
|
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
|
||||||
|
print('CallService: makeGsmCall result: $result');
|
||||||
|
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||||
|
if (resultMap["status"] != "calling") {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Failed to initiate call")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resultMap;
|
||||||
|
} catch (e) {
|
||||||
|
print("CallService: Error making call: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Error making call: $e")),
|
||||||
|
);
|
||||||
|
return {"status": "error", "message": e.toString()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
|
||||||
|
try {
|
||||||
|
print('CallService: Hanging up call');
|
||||||
|
final result = await _channel.invokeMethod('hangUpCall');
|
||||||
|
print('CallService: hangUpCall result: $result');
|
||||||
|
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||||
|
if (resultMap["status"] != "ended") {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Failed to end call")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resultMap;
|
||||||
|
} catch (e) {
|
||||||
|
print("CallService: Error hanging up call: $e");
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text("Error hanging up call: $e")),
|
||||||
|
);
|
||||||
|
return {"status": "error", "message": e.toString()};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
51
dialer/lib/services/contact_service.dart
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||||
|
import 'package:qr_flutter/qr_flutter.dart';
|
||||||
|
|
||||||
|
// Service to manage contact-related operations
|
||||||
|
class ContactService {
|
||||||
|
Future<List<Contact>> fetchContacts() async {
|
||||||
|
if (await FlutterContacts.requestPermission()) {
|
||||||
|
return await FlutterContacts.getContacts(
|
||||||
|
withProperties: true,
|
||||||
|
withThumbnail: true,
|
||||||
|
withAccounts: true,
|
||||||
|
withGroups: true,
|
||||||
|
withPhoto: true);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Contact>> fetchFavoriteContacts() async {
|
||||||
|
List<Contact> contacts = await fetchContacts();
|
||||||
|
return contacts.where((contact) => contact.isStarred).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> addNewContact(Contact contact) async {
|
||||||
|
await FlutterContacts.insertContact(contact);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to show an AlertDialog with a QR code for the contact's vCard
|
||||||
|
void showContactQRCodeDialog(BuildContext context, Contact contact) {
|
||||||
|
showDialog(
|
||||||
|
barrierColor: Colors.white24,
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
content: SizedBox(
|
||||||
|
width: 200,
|
||||||
|
height: 220,
|
||||||
|
child: QrImageView(
|
||||||
|
data: contact.toVCard(), // Generate vCard QR code
|
||||||
|
version: QrVersions.auto,
|
||||||
|
backgroundColor: Colors.white, // Make sure QR code is visible on black background
|
||||||
|
size: 200.0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
170
dialer/lib/services/cryptography/asymmetric_crypto_service.dart
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
|
class AsymmetricCryptoService {
|
||||||
|
static const MethodChannel _channel = MethodChannel('com.example.keystore');
|
||||||
|
final FlutterSecureStorage _secureStorage = FlutterSecureStorage();
|
||||||
|
final String _aliasPrefix = 'icing_';
|
||||||
|
final Uuid _uuid = Uuid();
|
||||||
|
|
||||||
|
/// Generates an ECDSA P-256 key pair with a unique alias and stores its metadata.
|
||||||
|
Future<String> generateKeyPair({String? label}) async {
|
||||||
|
try {
|
||||||
|
// Generate a unique identifier for the key
|
||||||
|
final String uuid = _uuid.v4();
|
||||||
|
final String alias = '$_aliasPrefix$uuid';
|
||||||
|
|
||||||
|
// Invoke native method to generate the key pair
|
||||||
|
await _channel.invokeMethod('generateKeyPair', {'alias': alias});
|
||||||
|
|
||||||
|
// Store key metadata securely
|
||||||
|
final Map<String, dynamic> keyMetadata = {
|
||||||
|
'alias': alias,
|
||||||
|
'label': label ?? 'Key $uuid',
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retrieve existing keys
|
||||||
|
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
||||||
|
List<dynamic> keysList = existingKeys != null ? jsonDecode(existingKeys) : [];
|
||||||
|
|
||||||
|
// Add the new key
|
||||||
|
keysList.add(keyMetadata);
|
||||||
|
|
||||||
|
// Save updated keys list
|
||||||
|
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
||||||
|
|
||||||
|
return alias;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception("Failed to generate key pair: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs data using the specified key alias.
|
||||||
|
Future<String> signData(String alias, String data) async {
|
||||||
|
try {
|
||||||
|
final String signature = await _channel.invokeMethod('signData', {
|
||||||
|
'alias': alias,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
|
return signature;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception("Failed to sign data with alias '$alias': ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves the public key for the specified alias.
|
||||||
|
Future<String> getPublicKey(String alias) async {
|
||||||
|
try {
|
||||||
|
final String publicKey = await _channel.invokeMethod('getPublicKey', {
|
||||||
|
'alias': alias,
|
||||||
|
});
|
||||||
|
return publicKey;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception("Failed to retrieve public key: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deletes the key pair associated with the specified alias and removes its metadata.
|
||||||
|
Future<void> deleteKeyPair(String alias) async {
|
||||||
|
try {
|
||||||
|
await _channel.invokeMethod('deleteKeyPair', {'alias': alias});
|
||||||
|
|
||||||
|
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
||||||
|
if (existingKeys != null) {
|
||||||
|
List<dynamic> keysList = jsonDecode(existingKeys);
|
||||||
|
keysList.removeWhere((key) => key['alias'] == alias);
|
||||||
|
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
||||||
|
}
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception("Failed to delete key pair: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieves all stored key metadata.
|
||||||
|
Future<List<Map<String, dynamic>>> getAllKeys() async {
|
||||||
|
try {
|
||||||
|
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
||||||
|
if (existingKeys == null) {
|
||||||
|
print("No keys found");
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
List<dynamic> keysList = jsonDecode(existingKeys);
|
||||||
|
return keysList.cast<Map<String, dynamic>>();
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception("Failed to retrieve keys: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks if a key pair exists for the given alias.
|
||||||
|
Future<bool> keyPairExists(String alias) async {
|
||||||
|
try {
|
||||||
|
final bool exists = await _channel.invokeMethod('keyPairExists', {'alias': alias});
|
||||||
|
return exists;
|
||||||
|
} on PlatformException catch (e) {
|
||||||
|
throw Exception("Failed to check key pair existence: ${e.message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initializes the default key pair if it doesn't exist.
|
||||||
|
Future<void> initializeDefaultKeyPair() async {
|
||||||
|
const String defaultAlias = 'icing_default';
|
||||||
|
final List<Map<String, dynamic>> keys = await getAllKeys();
|
||||||
|
|
||||||
|
// Check if the key exists in metadata
|
||||||
|
final bool defaultKeyExists = keys.any((key) => key['alias'] == defaultAlias);
|
||||||
|
|
||||||
|
if (!defaultKeyExists) {
|
||||||
|
await _channel.invokeMethod('generateKeyPair', {'alias': defaultAlias});
|
||||||
|
|
||||||
|
final Map<String, dynamic> keyMetadata = {
|
||||||
|
'alias': defaultAlias,
|
||||||
|
'label': 'Default Key',
|
||||||
|
'created_at': DateTime.now().toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
keys.add(keyMetadata);
|
||||||
|
await _secureStorage.write(key: 'keys', value: jsonEncode(keys));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the label of a key with the specified alias.
|
||||||
|
///
|
||||||
|
/// [alias]: The unique alias of the key to update.
|
||||||
|
/// [newLabel]: The new label to assign to the key.
|
||||||
|
///
|
||||||
|
/// Throws an exception if the key is not found or the update fails.
|
||||||
|
Future<void> updateKeyLabel(String alias, String newLabel) async {
|
||||||
|
try {
|
||||||
|
// Retrieve existing keys
|
||||||
|
final String? existingKeys = await _secureStorage.read(key: 'keys');
|
||||||
|
if (existingKeys == null) {
|
||||||
|
throw Exception("No keys found to update.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> keysList = jsonDecode(existingKeys);
|
||||||
|
|
||||||
|
// Find the key with the specified alias
|
||||||
|
bool keyFound = false;
|
||||||
|
for (var key in keysList) {
|
||||||
|
if (key['alias'] == alias) {
|
||||||
|
key['label'] = newLabel;
|
||||||
|
keyFound = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!keyFound) {
|
||||||
|
throw Exception("Key with alias \"$alias\" not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated keys list
|
||||||
|
await _secureStorage.write(key: 'keys', value: jsonEncode(keysList));
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception("Failed to update key label: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
91
dialer/lib/services/obfuscate_service.dart
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// lib/services/obfuscate_service.dart
|
||||||
|
import 'package:dialer/widgets/color_darkener.dart';
|
||||||
|
|
||||||
|
import '../../globals.dart' as globals;
|
||||||
|
import 'dart:ui';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ObfuscateService {
|
||||||
|
// Private constructor
|
||||||
|
ObfuscateService._privateConstructor();
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
static final ObfuscateService _instance = ObfuscateService._privateConstructor();
|
||||||
|
|
||||||
|
// Factory constructor to return the same instance
|
||||||
|
factory ObfuscateService() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method to obfuscate data
|
||||||
|
String obfuscateData(String data) {
|
||||||
|
if (globals.isStealthMode) {
|
||||||
|
return _obfuscateData(data);
|
||||||
|
} else {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private helper method for obfuscation logic
|
||||||
|
String _obfuscateData(String data) {
|
||||||
|
if (data.isNotEmpty) {
|
||||||
|
// Ensure the string has at least two characters to obfuscate
|
||||||
|
if (data.length == 1) {
|
||||||
|
return '${data[0]}';
|
||||||
|
} else {
|
||||||
|
return '${data[0]}...${data[data.length - 1]}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ObfuscatedAvatar extends StatelessWidget {
|
||||||
|
final Uint8List? imageBytes;
|
||||||
|
final double radius;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final String? fallbackInitial;
|
||||||
|
|
||||||
|
const ObfuscatedAvatar({
|
||||||
|
Key? key,
|
||||||
|
required this.imageBytes,
|
||||||
|
this.radius = 25,
|
||||||
|
this.backgroundColor = Colors.grey,
|
||||||
|
this.fallbackInitial,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (imageBytes != null && imageBytes!.isNotEmpty) {
|
||||||
|
return ClipOval(
|
||||||
|
child: ImageFiltered(
|
||||||
|
imageFilter: globals.isStealthMode
|
||||||
|
? ImageFilter.blur(sigmaX: 10, sigmaY: 10)
|
||||||
|
: ImageFilter.blur(sigmaX: 0, sigmaY: 0),
|
||||||
|
child: Image.memory(
|
||||||
|
imageBytes!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: radius * 2,
|
||||||
|
height: radius * 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: radius,
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
child: Text(
|
||||||
|
fallbackInitial != null && fallbackInitial!.isNotEmpty
|
||||||
|
? fallbackInitial![0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: TextStyle(
|
||||||
|
color: darken(backgroundColor),
|
||||||
|
fontSize: radius,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
72
dialer/lib/widgets/qr_scanner.dart
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
|
|
||||||
|
class QRCodeScannerScreen extends StatefulWidget {
|
||||||
|
const QRCodeScannerScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
_QRCodeScannerScreenState createState() => _QRCodeScannerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QRCodeScannerScreenState extends State<QRCodeScannerScreen> {
|
||||||
|
final MobileScannerController cameraController = MobileScannerController();
|
||||||
|
bool isScanning = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
cameraController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showInvalidQRDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Invalid QR Code'),
|
||||||
|
content:
|
||||||
|
const Text('The scanned QR code does not contain valid vCard data.'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop(); // Close dialog
|
||||||
|
setState(() {
|
||||||
|
isScanning = true; // Resume scanning
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Try Again'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Scan Contact QR Code'),
|
||||||
|
),
|
||||||
|
body: MobileScanner(
|
||||||
|
controller: cameraController,
|
||||||
|
// allowDuplicates: false, // or true, depending on your preference
|
||||||
|
onDetect: (capture) {
|
||||||
|
if (!isScanning) return;
|
||||||
|
isScanning = false; // Stop multiple triggers
|
||||||
|
|
||||||
|
final List<Barcode> barcodes = capture.barcodes;
|
||||||
|
for (final barcode in barcodes) {
|
||||||
|
final String? code = barcode.rawValue;
|
||||||
|
// If the QR code contains 'BEGIN:VCARD', let's assume it's a valid vCard
|
||||||
|
if (code != null && code.contains('BEGIN:VCARD')) {
|
||||||
|
Navigator.pop(context, code); // pop back with the full vCard text
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid vCard was found in any of the barcodes
|
||||||
|
_showInvalidQRDialog();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -34,10 +34,29 @@ dependencies:
|
|||||||
|
|
||||||
# The following adds the Cupertino Icons font to your application.
|
# The following adds the Cupertino Icons font to your application.
|
||||||
# Use with the CupertinoIcons class for iOS style icons.
|
# Use with the CupertinoIcons class for iOS style icons.
|
||||||
|
shared_preferences: ^2.3.3 # Local storage (no critical data)
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_contacts: ^1.1.9+2
|
flutter_contacts: ^1.1.9+2
|
||||||
permission_handler: ^10.2.0 # For handling permissions
|
permission_handler: ^11.3.1 # For handling permissions
|
||||||
cached_network_image: ^3.2.3 # For caching contact images
|
cached_network_image: ^3.2.3 # For caching contact images
|
||||||
|
qr_flutter: ^4.1.0
|
||||||
|
camera: ^0.10.0+2
|
||||||
|
mobile_scanner: ^6.0.2
|
||||||
|
pretty_qr_code: ^3.3.0
|
||||||
|
pointycastle: ^3.4.0
|
||||||
|
file_picker: ^8.1.6
|
||||||
|
asn1lib: ^1.0.0
|
||||||
|
intl_utils: ^2.0.7
|
||||||
|
url_launcher: ^6.3.1
|
||||||
|
flutter_secure_storage: ^9.0.0
|
||||||
|
audioplayers: ^6.1.0
|
||||||
|
cryptography: ^2.0.0
|
||||||
|
convert: ^3.0.1
|
||||||
|
encrypt: ^5.0.3
|
||||||
|
uuid: ^4.5.1
|
||||||
|
provider: ^6.1.2
|
||||||
|
|
||||||
|
intl: any
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
10
dialer/run.sh
Executable file
@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash -e
|
||||||
|
|
||||||
|
IMG=git.gmoker.com/icing/flutter:main
|
||||||
|
|
||||||
|
if [ "$1" == '-s' ]; then
|
||||||
|
OPT+=(--dart-define=STEALTH=true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
set -x
|
||||||
|
docker run --rm -p 5037:5037 -v "$PWD:/app/" "$IMG" run "${OPTS[@]}"
|
4
dialer/stealth_local_run.sh
Executable file
@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
echo "Running Icing Dialer in STEALTH mode..."
|
||||||
|
flutter run --dart-define=STEALTH=true
|
@ -13,7 +13,7 @@ import 'package:dialer/main.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
// Build our app and trigger a frame.
|
// Build our app and trigger a frame.
|
||||||
await tester.pumpWidget(const MyApp());
|
await tester.pumpWidget(const Dialer());
|
||||||
|
|
||||||
// Verify that our counter starts at 0.
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('0'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
5
docs/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
!*.md
|
||||||
|
!*.pdf
|
||||||
|
!*.sh
|
||||||
|
!.gitignore
|
72
docs/Icing.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Icing
|
||||||
|
|
||||||
|
An Epitech Innovation Project
|
||||||
|
|
||||||
|
*By*
|
||||||
|
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- [Introduction to Icing](#introducingtoicing)
|
||||||
|
- [Strategy](#icingsstrategy)
|
||||||
|
- [Technology choices]()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Introduction to Icing
|
||||||
|
|
||||||
|
Icing is the name of our project, which is divided in **two interconnected goals**:
|
||||||
|
1. Provide an end-to-end (E2E) encryption **code library**, based on Eliptic Curve Cryptography (ECC), to encrypt phone-calls on an **analog audio** level.
|
||||||
|
2. Provide a reference implementation in the form of a totally seamless Android **smartphone dialer** application, that anybody could use without being aware of its encryption feature.
|
||||||
|
|
||||||
|
This idea came naturally to our minds, when we remarked the lack of such tool.
|
||||||
|
|
||||||
|
Where "private messaging" and other "encrypted communication" apps flourish, nowadays, they **all** require an internet access to work.
|
||||||
|
|
||||||
|
### Privacy and security in telecoms should not depend on internet availability.
|
||||||
|
|
||||||
|
We are conscious that ourselves, and our surroundings, grew up in Global North, with simple and cheap internet and telecommunication access, but we should not forget that on a global point of view, it is estimated that less than 20% of the world's stepable land is covered with 3G/4G/+ network.
|
||||||
|
Standard "low-tech" GSM network coverage is almost twice that.
|
||||||
|
|
||||||
|
So in a real-world, stressful and harsh condition, affording privacy or security in telecommunication is usually too much of a luxury; and we should change that.
|
||||||
|
|
||||||
|
Our solution is for the every-man that is not even aware of its smart phone weakness, as well as for the activists or journalists surviving in hostile environment around the globe.
|
||||||
|
|
||||||
|
|
||||||
|
### Setting a new security standard
|
||||||
|
|
||||||
|
#### ***"There is no way to create a backdoor that only the good guys can walk through"***
|
||||||
|
> (*Meredith Whittaker - President of Signal Fundation - July 2023, Channel 4*)
|
||||||
|
|
||||||
|
If the police can listen to your calls with a mandate, hackers can, without mandate.
|
||||||
|
|
||||||
|
Many online platforms, such as online bank accounts, uses phone calls, or voicemails to drop security codes needed for authentication. The idea is to bring extra security, by requiring a second factor to authenticate the user, but most voicemails security features have been obsolete for a long time now.
|
||||||
|
|
||||||
|
**But this could change with globalized end-to-end encryption.**
|
||||||
|
|
||||||
|
This not only enables obfuscation of the transmitted audio data, but also hard peer authentication.
|
||||||
|
This means that if you are in an important call, where you could communicate sensitive information such as passwords, or financial orders, using Icing protocol you and your peer would know that there is no man in the middle, listening and stealing information, and that your correspondent really is who it says.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Icing's strategy
|
||||||
|
|
||||||
|
We focus on FOSS community as a primary target.
|
||||||
|
|
||||||
|
Our reference implementation, the Iced dialer, is destined to replace any stock dialer app from any android smartphone.
|
||||||
|
|
||||||
|
Alternative open-source and privacy-focused Android distributions, such as GrapheneOS, are major targets.
|
||||||
|
|
||||||
|
Their community are thriving, and could help our open-source development.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Technology choices
|
||||||
|
|
||||||
|
We chose to code with Flutter, the Dart framework.
|
||||||
|
Even though this choice gives us quick-delivery capabilities, we will need to switch language for lower levels development, such as sound stream caption, encryption, compression, encoding, and transmission.
|
||||||
|
|
||||||
|
The language for these manoeuvres is not determined yet, but Go, Rust, Kotlin and Java are good candidates.
|
||||||
|
|
7831
docs/Projet Icing GONOGO.pdf
Normal file
71
docs/UserManual.md
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
|
||||||
|
# User Manual
|
||||||
|
|
||||||
|
|
||||||
|
**Utilization documentation.**
|
||||||
|
|
||||||
|
Written with chapters for the average Joe user, security experts, and developers.
|
||||||
|
|
||||||
|
The average-user section is only about what the average-user will know from Icing: its dialer reference implementation.
|
||||||
|
|
||||||
|
The security expert section will cover all the theory behind our reference implementation, and the Icing protocol. This section can serve as an introduction / transition for the next section:
|
||||||
|
|
||||||
|
The developer section will explain our code architecture and concepts, going in-depth inside the reference implementation and the Icing protocol library.
|
||||||
|
This library will have dedicated documentation in this section, so any developer can implement it in any desired way.
|
||||||
|
|
||||||
|
Lastly, as a continuation of the developer section, the Manual Test section will cover our manual testing policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- [Average User](#averageuser)
|
||||||
|
|
||||||
|
- [Security Expert](#icingsstrategy)
|
||||||
|
|
||||||
|
- [Developer](#developer)
|
||||||
|
|
||||||
|
- [Manual Tests](#manualtests)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Average User
|
||||||
|
|
||||||
|
|
||||||
|
Use the Icing dialer like your normal dialer, if you can't do that we can't help, you dumb retard lmfao.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Security Expert
|
||||||
|
|
||||||
|
SecUriTy eXpeRt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Developer
|
||||||
|
|
||||||
|
int main;
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual Tests
|
||||||
|
|
||||||
|
1. Call grandpa
|
||||||
|
2. Receive mum call
|
||||||
|
3. Order 150g of 95% pure Bolivian coke without encryption
|
||||||
|
4. Order again but with encryption
|
||||||
|
5. Compare results
|
243
docs/beta_test_plan.md
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
# Beta Test Plan
|
||||||
|
|
||||||
|
## Core Functionalities
|
||||||
|
|
||||||
|
---
|
||||||
|
### Action Plan review:
|
||||||
|
|
||||||
|
In our previous Action Plan, we listed the following functionnal specifications:
|
||||||
|
- Phone call encryption between two known pairs, that exchanged keys in person. *Mandatory*
|
||||||
|
- Phone dialer that is discret and functional, and should not disturb a normal use (clear phone call). *Mandatory*
|
||||||
|
- Phone call encryption between two unknown pairs, with key exchange on the go. Optional.
|
||||||
|
- SMS encryption between two known pairs (in person key exchange). Optional.
|
||||||
|
|
||||||
|
We now retain only the two first functional specifications.
|
||||||
|
|
||||||
|
### Core Functionalities
|
||||||
|
|
||||||
|
Based on this review, here are all the core functionnalities we set:
|
||||||
|
|
||||||
|
#### Icing protocol
|
||||||
|
- Advanced protocol documentation, paving the way for a full RFC.
|
||||||
|
|
||||||
|
The protocol definition will include as completed:
|
||||||
|
- Peer ping
|
||||||
|
- Ephemeral key gestion
|
||||||
|
- Perfect Forward Secrecy
|
||||||
|
- Handshakes
|
||||||
|
- Real-time data-stream encryption (and decryption)
|
||||||
|
- Encrypted stream compression
|
||||||
|
- Transmission over audio stream (at least one modulation type)
|
||||||
|
- First steps in FEC (Forward Error Correction): detecting half of transmission errors
|
||||||
|
|
||||||
|
And should include prototype or scratches functionalities, among which:
|
||||||
|
- Embedded silent data transmission (such as DTMF)
|
||||||
|
- On-the-fly key exchange (does not require prior key exchange, sacrifying some security)
|
||||||
|
- Stronger FEC: detecting >80%, correcting 20% of transmission errors
|
||||||
|
|
||||||
|
#### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation)
|
||||||
|
|
||||||
|
The Icing dialer should be a fully transparent and almost undistinguishable smartphone dialer.
|
||||||
|
Any Icing-unaware user should be able to use the dialer smoothly to make calls to anyone.
|
||||||
|
The dialer should propose a full set of functionnalities to handle its Icing protocol implementation.
|
||||||
|
|
||||||
|
Here is the list of all the functionnalities our dialer will integrate:
|
||||||
|
|
||||||
|
- Call
|
||||||
|
- Ringtone on incoming call
|
||||||
|
- Incoming and ongoing call notification
|
||||||
|
- Complete dialer with all numbers, star *, pound #
|
||||||
|
- Mute button
|
||||||
|
- Speaker button
|
||||||
|
- Normal call
|
||||||
|
- DTMF transmission
|
||||||
|
- SIM choice on call
|
||||||
|
|
||||||
|
- Encrypted Call
|
||||||
|
- Encrypted call if pair public key is known
|
||||||
|
- Encrypted DTMF transmission
|
||||||
|
- Data rate indicator
|
||||||
|
- Data error indicator
|
||||||
|
- Disable encryption button
|
||||||
|
|
||||||
|
- Call history
|
||||||
|
- Call details (timedate, duration, ring number)
|
||||||
|
- Missed calls filter
|
||||||
|
- Outgoing calls filter
|
||||||
|
- Incoming calls filter
|
||||||
|
- Call back function
|
||||||
|
- Contact modal on history tap
|
||||||
|
- Block call number
|
||||||
|
|
||||||
|
- Contacts
|
||||||
|
- Sorted contact listing
|
||||||
|
- Contact creation / editing buttons
|
||||||
|
- Contact sharing via QR code / VCF
|
||||||
|
- Contact search bar (application wide)
|
||||||
|
- Favorite contacts
|
||||||
|
- Contact preview (picture, number, public key...)
|
||||||
|
|
||||||
|
- Visual voicemail
|
||||||
|
- Play / Pause
|
||||||
|
- Notification
|
||||||
|
- Quick link to call, text, block, share number...
|
||||||
|
|
||||||
|
- Miscellanous
|
||||||
|
- Settings menu
|
||||||
|
- Version number
|
||||||
|
- Storage of user public keys
|
||||||
|
- Blocklist gestion (list / add / del / search)
|
||||||
|
- Default SIM choice
|
||||||
|
|
||||||
|
- Asymetric Keys
|
||||||
|
- Secure storage
|
||||||
|
- Generation at startup if missing
|
||||||
|
- Full key management (list / add / del / search / share)
|
||||||
|
- Secure generation (Android Keystore generation)
|
||||||
|
- Insecure generation (RAM generation)
|
||||||
|
- Exportation on creation (implies insecure generation)
|
||||||
|
- Importation
|
||||||
|
- Trust shift (shift trust from contacts)
|
||||||
|
|
||||||
|
|
||||||
|
## Beta Testing Scenarios
|
||||||
|
- Clear call from Icing dialer to another dialer (Google, Apple...)
|
||||||
|
- Clear call from Icing dialer to another Icing dialer
|
||||||
|
- Clear call from Icing dialer to an icing pubkey-known contact but without Icing dialer
|
||||||
|
- Encrypted call from Icing dialer to a known contact with Icing dialer
|
||||||
|
- Encrypted call from Icing dialer to an unknown contact with Icing dialer
|
||||||
|
- Create / Edit / Save contact with(out) public key
|
||||||
|
- Share contact as QR code / Vcard
|
||||||
|
- Import contact from QR code / Vcard
|
||||||
|
- Listen to voicemail
|
||||||
|
- Record encrypted call and check the encryption
|
||||||
|
- Change default SIM
|
||||||
|
|
||||||
|
## User Journeys
|
||||||
|
|
||||||
|
Mathilda, 34 years-old, connects to her PayPal account from a new device.
|
||||||
|
To authenticate herself, PayPal sends her a code on her voicemail.
|
||||||
|
Mathilda being aware of the risks of this technology, she has set up strong Icing authentication with her network provider by registering a pair of her Icing public keys.
|
||||||
|
When she calls her voicemail, Icing protocol is triggered and checks for her key authentication ;
|
||||||
|
it will fail if the caller does not pocesses the required Icing keys.
|
||||||
|
Mathilda is thus the only one granted access, and she can retreive her PayPal code securely.
|
||||||
|
|
||||||
|
Jeff, 70 years-old, calls his bank after he had a problem on his bank app.
|
||||||
|
The remote bank advisor asks him to authenticate, making him type his password on the phone dialer.
|
||||||
|
By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely,
|
||||||
|
but also that the call is coming from Jeff's phone and not an impersonator.
|
||||||
|
|
||||||
|
Elise, 42 years-old, is a journalist covering sensitive topics.
|
||||||
|
Her work draws attention from people who want to know what she's saying - and to whom.
|
||||||
|
Forced to stay discreet, with unreliable signal and a likely monitored phone line,
|
||||||
|
she uses Icing dialer to make secure calls without exposing herself.
|
||||||
|
|
||||||
|
Paul, a 22 years-old developer, is enjoying its vacations abroad.
|
||||||
|
But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is
|
||||||
|
qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in his country.
|
||||||
|
With Icing dialer, he can call his collegues and help fix the problem, completely safe.
|
||||||
|
|
||||||
|
## Evaluation Criteria
|
||||||
|
### Protocol and lib
|
||||||
|
1. Security
|
||||||
|
- Encryption Strength: Ensure that the encryption algorithms used (AES-256, ECC)
|
||||||
|
are up-to-date and secure.
|
||||||
|
- Key Management: Evaluate the mechanism for generating, distributing, and
|
||||||
|
storing encryption keys (P-256 keys, ECDH).
|
||||||
|
- Forward Secrecy: Confirm that the protocol supports forward secrecy, meaning
|
||||||
|
that session keys are discarded after use to prevent future decryption of
|
||||||
|
past communication, and that future sessions are salted with a pseudo-random salt
|
||||||
|
resulting or derived from the past calls.
|
||||||
|
- End-to-End Encryption Integrity: Verify that no clear data is exposed
|
||||||
|
outside the encryption boundary (client-side only).
|
||||||
|
- Replay Protection: Ensure that the protocol includes strong mechanisms to prevent replay
|
||||||
|
attacks.
|
||||||
|
|
||||||
|
2. Performance
|
||||||
|
- Latency: Measure the round-trip time (RTT) for call setup and audio quality
|
||||||
|
during the call. The system should aim for the lowes latency possible.
|
||||||
|
- Bandwidth Efficiency: Evaluate the protocol’s ability to optimize bandwidth
|
||||||
|
usage while maintaining acceptable audio quality.
|
||||||
|
- Audio Quality: Assess the audio quality during calls, including clarity,
|
||||||
|
consistency, and minimal distortion.
|
||||||
|
|
||||||
|
3. Usability
|
||||||
|
- Ease of Integration: Evaluate how easy it is to integrate the library into an
|
||||||
|
Android application, including the availability of well-documented APIs and
|
||||||
|
clear examples.
|
||||||
|
- Seamless User Experience: Check for smooth call initiation, handling of
|
||||||
|
dropped calls, and reconnection strategies. The app should handle background
|
||||||
|
operation gracefully.
|
||||||
|
- UI/UX Design: Assess the user interface (UI) of the Android dialer for intuitiveness,
|
||||||
|
accessibility, and if it could be a drop-in replacement for the original dialer.
|
||||||
|
- Error Handling and Recovery: Evaluate how the system handles unexpected
|
||||||
|
errors (e.g., network issues, connection drops) and recovers from them.
|
||||||
|
|
||||||
|
4. Interoperability
|
||||||
|
- Support for Multiple Protocols: Verify if the protocol can
|
||||||
|
integrate with existing standards (e.g., SIP, WebRTC) for interoperability
|
||||||
|
with other services.
|
||||||
|
- Cross-device Compatibility: Ensure that calls encryption can be initiated and received
|
||||||
|
across different devices, operating systems, and network conditions.
|
||||||
|
- Backward Compatibility: Test whether the protocol is backward compatible.
|
||||||
|
|
||||||
|
5. Privacy
|
||||||
|
- Data Storage: Evaluate how the system stores any data (user details, identities).
|
||||||
|
Ensure that sensitive information is encrypted.
|
||||||
|
- Data Minimization: Ensure that only the minimum necessary data is used
|
||||||
|
for the protocol to function.
|
||||||
|
- No Call Metadata Storage: Ensure that no metadata (e.g., call logs, duration,
|
||||||
|
timestamps) is stored unless necessary, and, if stored, it should be
|
||||||
|
encrypted.
|
||||||
|
|
||||||
|
6. Maintainability
|
||||||
|
- Code Quality: Review the library for clarity, readability, and
|
||||||
|
maintainability of the code. It should be modular and well-documented.
|
||||||
|
- Documentation: Ensure that the protocol and library come with thorough
|
||||||
|
documentation, including how-to guides and troubleshooting resources.
|
||||||
|
- Active Development and Community: Check the active development of the
|
||||||
|
protocol and library (open-source contributions, GitHub repository activity).
|
||||||
|
|
||||||
|
### Dialer
|
||||||
|
1. User Interface
|
||||||
|
- Design and Layout: Ensure that the dialer interface is simple, intuitive, and
|
||||||
|
easy to navigate. Buttons should be appropriately sized, and layout should
|
||||||
|
prioritize accessibility.
|
||||||
|
- Dialer Search and History: Ensure there’s an efficient contact search,
|
||||||
|
history logging, and favorites integration.
|
||||||
|
- Visual Feedback: Verify that the app most usefull buttons provides visual feedback for actions,
|
||||||
|
such as dialling, calls available interactions for example.
|
||||||
|
|
||||||
|
2. Call Management
|
||||||
|
- Call Initiation: Test the ease of initiating a call from contact list, recent
|
||||||
|
call logs, contact search or direct number input.
|
||||||
|
- Incoming Call Handling: Verify the visual and audio prompts when receiving
|
||||||
|
calls, including notifications for missed calls.
|
||||||
|
- Call Hold/Transfer/Forward: Ensure the dialer supports call hold, transfer,
|
||||||
|
and forwarding features.
|
||||||
|
- Audio Controls: Check whether the app allows users to adjust speaker volume,
|
||||||
|
mute, and switch between earpiece/speakerphone.
|
||||||
|
|
||||||
|
3. Integration with System Features
|
||||||
|
- Permissions: Ensure the app requests and manages necessary permissions
|
||||||
|
(microphone, camera for scanning QR codes, contacts, call history, local storage).
|
||||||
|
- Integration with Contacts: Ensure that the app seamlessly integrates with the
|
||||||
|
Android contacts and syncs correctly with the address book.
|
||||||
|
- Notifications: Ensure that call notifications and ringtone works even when the app is in
|
||||||
|
the background or the phone is locked.
|
||||||
|
|
||||||
|
4. Resource Management
|
||||||
|
- Resource Efficiency: Ensure the app doesn’t excessively consume CPU or memory
|
||||||
|
while operating, during idle times or on call.
|
||||||
|
|
||||||
|
5. Security and Privacy
|
||||||
|
- App Encryption: Ensure that any stored and sensitive data is
|
||||||
|
encrypted, or protected.
|
||||||
|
- Secure Call Handling: Verify that calls are handled securely through the
|
||||||
|
encrypted voice protocol.
|
||||||
|
- Minimal Permissions: The app should ask for the least amount of permissions
|
||||||
|
necessary to function.
|
||||||
|
|
||||||
|
6. Reliability
|
||||||
|
- Crash Resistance: Test for the app’s stability, ensuring it doesn't crash or
|
||||||
|
freeze during use.
|
62
docs/non-functional_delivrables.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Project Deliverables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common
|
||||||
|
|
||||||
|
### Develop and retain a user community
|
||||||
|
|
||||||
|
We plan to create a user community where users can share their experiences with the project and provide feedback on some social platforms such as Telegram, Discord, or Matrix.
|
||||||
|
|
||||||
|
The goal is to promote our project in different open-source and security and privacy-focused communities to gather experienced users capable of interesting feedbacks.
|
||||||
|
|
||||||
|
As we do not focus on selling a product to anyone, but rather to develop an open-source protocol, user retention is not a priority, and it will be more of a KPI of the project's pertinence than a goal; this means we will focus on listening and taking into account good feedback rather than publishing funny posts on social media.
|
||||||
|
|
||||||
|
### Work on user experience
|
||||||
|
|
||||||
|
We will work on making the dialer user-friendly and easy to use.
|
||||||
|
|
||||||
|
We are confident in our current UX development path, and user feedback will be taken into account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Specifications
|
||||||
|
|
||||||
|
### Enhance credibility and grow project's reputation
|
||||||
|
|
||||||
|
- **Transparent Development:**
|
||||||
|
Maintain a public roadmap and changelog to document every update and decision during the project's lifecycle.
|
||||||
|
|
||||||
|
- **Security Audits:**
|
||||||
|
We will rely on our automatic tests and community experts to have organic and constant auditing.
|
||||||
|
|
||||||
|
- **Community Engagement:**
|
||||||
|
Actively involve our user community in discussions, bug reports, and feature requests. Regularly update the community on progress and upcoming changes.
|
||||||
|
|
||||||
|
- **Open Source Best Practices:**
|
||||||
|
Adhere to industry-standard coding practices, thorough documentation, and continuous integration/deployment pipelines to ensure high-quality, maintainable code.
|
||||||
|
|
||||||
|
- **Visibility in Key Forums:**
|
||||||
|
Present and share our work in open-source, cybersecurity, and privacy-focused conferences and events to enhance credibility and attract constructive feedback.
|
||||||
|
|
||||||
|
### optimize relationships with the target audience
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Establish strategic partnership
|
||||||
|
|
||||||
|
- **Academic Collaborations:**
|
||||||
|
Partner with academic institutions for research initiatives and validation of our protocol, leveraging their expertise for further improvements.
|
||||||
|
|
||||||
|
- **Industry Alliances:**
|
||||||
|
Seek partnerships with established players in the open-source software industry to benefit from their wide community coverage, such as AOSP / GrapheneOS / LineageOS.
|
||||||
|
|
||||||
|
- **Integration Opportunities:**
|
||||||
|
Explore collaborations with mobile operating systems (e.g., AOSP) and VoIP providers to integrate Icing into existing communication infrastructures.
|
||||||
|
|
||||||
|
- **Joint Innovation Projects:**
|
||||||
|
Engage in co-development efforts that align with our mission, ensuring that both parties contribute to and benefit from technological advancements.
|
||||||
|
|
||||||
|
- **Funding and Support:**
|
||||||
|
Identify and pursue grants, sponsorships, and research funding that align with the project's objectives, ensuring sustainable development.
|
||||||
|
|
34
ios/.gitignore
vendored
@ -1,34 +0,0 @@
|
|||||||
**/dgph
|
|
||||||
*.mode1v3
|
|
||||||
*.mode2v3
|
|
||||||
*.moved-aside
|
|
||||||
*.pbxuser
|
|
||||||
*.perspectivev3
|
|
||||||
**/*sync/
|
|
||||||
.sconsign.dblite
|
|
||||||
.tags*
|
|
||||||
**/.vagrant/
|
|
||||||
**/DerivedData/
|
|
||||||
Icon?
|
|
||||||
**/Pods/
|
|
||||||
**/.symlinks/
|
|
||||||
profile
|
|
||||||
xcuserdata
|
|
||||||
**/.generated/
|
|
||||||
Flutter/App.framework
|
|
||||||
Flutter/Flutter.framework
|
|
||||||
Flutter/Flutter.podspec
|
|
||||||
Flutter/Generated.xcconfig
|
|
||||||
Flutter/ephemeral/
|
|
||||||
Flutter/app.flx
|
|
||||||
Flutter/app.zip
|
|
||||||
Flutter/flutter_assets/
|
|
||||||
Flutter/flutter_export_environment.sh
|
|
||||||
ServiceDefinitions.json
|
|
||||||
Runner/GeneratedPluginRegistrant.*
|
|
||||||
|
|
||||||
# Exceptions to above rules.
|
|
||||||
!default.mode1v3
|
|
||||||
!default.mode2v3
|
|
||||||
!default.pbxuser
|
|
||||||
!default.perspectivev3
|
|
@ -1,26 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
|
||||||
<string>en</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>App</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>io.flutter.flutter.app</string>
|
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
|
||||||
<string>6.0</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>App</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>FMWK</string>
|
|
||||||
<key>CFBundleShortVersionString</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>CFBundleSignature</key>
|
|
||||||
<string>????</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>1.0</string>
|
|
||||||
<key>MinimumOSVersion</key>
|
|
||||||
<string>12.0</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -1 +0,0 @@
|
|||||||
#include "Generated.xcconfig"
|
|
@ -1 +0,0 @@
|
|||||||
#include "Generated.xcconfig"
|
|
@ -1,616 +0,0 @@
|
|||||||
// !$*UTF8*$!
|
|
||||||
{
|
|
||||||
archiveVersion = 1;
|
|
||||||
classes = {
|
|
||||||
};
|
|
||||||
objectVersion = 54;
|
|
||||||
objects = {
|
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
|
||||||
/* End PBXBuildFile section */
|
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
|
||||||
331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
|
|
||||||
isa = PBXContainerItemProxy;
|
|
||||||
containerPortal = 97C146E61CF9000F007C117D /* Project object */;
|
|
||||||
proxyType = 1;
|
|
||||||
remoteGlobalIDString = 97C146ED1CF9000F007C117D;
|
|
||||||
remoteInfo = Runner;
|
|
||||||
};
|
|
||||||
/* End PBXContainerItemProxy section */
|
|
||||||
|
|
||||||
/* Begin PBXCopyFilesBuildPhase section */
|
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */ = {
|
|
||||||
isa = PBXCopyFilesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
dstPath = "";
|
|
||||||
dstSubfolderSpec = 10;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
name = "Embed Frameworks";
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
|
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
|
||||||
isa = PBXFrameworksBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXFrameworksBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */,
|
|
||||||
);
|
|
||||||
path = RunnerTests;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */,
|
|
||||||
);
|
|
||||||
name = Flutter;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
97C146E51CF9000F007C117D = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
9740EEB11CF90186004384FC /* Flutter */,
|
|
||||||
97C146F01CF9000F007C117D /* Runner */,
|
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
|
||||||
);
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
97C146EF1CF9000F007C117D /* Products */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */,
|
|
||||||
331C8081294A63A400263BE5 /* RunnerTests.xctest */,
|
|
||||||
);
|
|
||||||
name = Products;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
97C146F01CF9000F007C117D /* Runner */ = {
|
|
||||||
isa = PBXGroup;
|
|
||||||
children = (
|
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */,
|
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */,
|
|
||||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
|
|
||||||
97C147021CF9000F007C117D /* Info.plist */,
|
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
|
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
|
||||||
);
|
|
||||||
path = Runner;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXGroup section */
|
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
|
||||||
buildPhases = (
|
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
331C8086294A63A400263BE5 /* PBXTargetDependency */,
|
|
||||||
);
|
|
||||||
name = RunnerTests;
|
|
||||||
productName = RunnerTests;
|
|
||||||
productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
|
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
|
||||||
};
|
|
||||||
97C146ED1CF9000F007C117D /* Runner */ = {
|
|
||||||
isa = PBXNativeTarget;
|
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
|
||||||
buildPhases = (
|
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
|
||||||
);
|
|
||||||
buildRules = (
|
|
||||||
);
|
|
||||||
dependencies = (
|
|
||||||
);
|
|
||||||
name = Runner;
|
|
||||||
productName = Runner;
|
|
||||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
|
||||||
productType = "com.apple.product-type.application";
|
|
||||||
};
|
|
||||||
/* End PBXNativeTarget section */
|
|
||||||
|
|
||||||
/* Begin PBXProject section */
|
|
||||||
97C146E61CF9000F007C117D /* Project object */ = {
|
|
||||||
isa = PBXProject;
|
|
||||||
attributes = {
|
|
||||||
BuildIndependentTargetsInParallel = YES;
|
|
||||||
LastUpgradeCheck = 1510;
|
|
||||||
ORGANIZATIONNAME = "";
|
|
||||||
TargetAttributes = {
|
|
||||||
331C8080294A63A400263BE5 = {
|
|
||||||
CreatedOnToolsVersion = 14.0;
|
|
||||||
TestTargetID = 97C146ED1CF9000F007C117D;
|
|
||||||
};
|
|
||||||
97C146ED1CF9000F007C117D = {
|
|
||||||
CreatedOnToolsVersion = 7.3.1;
|
|
||||||
LastSwiftMigration = 1100;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
};
|
|
||||||
buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
|
|
||||||
compatibilityVersion = "Xcode 9.3";
|
|
||||||
developmentRegion = en;
|
|
||||||
hasScannedForEncodings = 0;
|
|
||||||
knownRegions = (
|
|
||||||
en,
|
|
||||||
Base,
|
|
||||||
);
|
|
||||||
mainGroup = 97C146E51CF9000F007C117D;
|
|
||||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
|
||||||
projectDirPath = "";
|
|
||||||
projectRoot = "";
|
|
||||||
targets = (
|
|
||||||
97C146ED1CF9000F007C117D /* Runner */,
|
|
||||||
331C8080294A63A400263BE5 /* RunnerTests */,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
/* End PBXProject section */
|
|
||||||
|
|
||||||
/* Begin PBXResourcesBuildPhase section */
|
|
||||||
331C807F294A63A400263BE5 /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
97C146EC1CF9000F007C117D /* Resources */ = {
|
|
||||||
isa = PBXResourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
|
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
|
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXResourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
|
|
||||||
);
|
|
||||||
name = "Thin Binary";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
|
||||||
};
|
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
alwaysOutOfDate = 1;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Run Script";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
|
||||||
};
|
|
||||||
/* End PBXShellScriptBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
|
||||||
331C807D294A63A400263BE5 /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
97C146EA1CF9000F007C117D /* Sources */ = {
|
|
||||||
isa = PBXSourcesBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
};
|
|
||||||
/* End PBXSourcesBuildPhase section */
|
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
|
||||||
331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
|
|
||||||
isa = PBXTargetDependency;
|
|
||||||
target = 97C146ED1CF9000F007C117D /* Runner */;
|
|
||||||
targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
|
|
||||||
};
|
|
||||||
/* End PBXTargetDependency section */
|
|
||||||
|
|
||||||
/* Begin PBXVariantGroup section */
|
|
||||||
97C146FA1CF9000F007C117D /* Main.storyboard */ = {
|
|
||||||
isa = PBXVariantGroup;
|
|
||||||
children = (
|
|
||||||
97C146FB1CF9000F007C117D /* Base */,
|
|
||||||
);
|
|
||||||
name = Main.storyboard;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
|
|
||||||
isa = PBXVariantGroup;
|
|
||||||
children = (
|
|
||||||
97C147001CF9000F007C117D /* Base */,
|
|
||||||
);
|
|
||||||
name = LaunchScreen.storyboard;
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXVariantGroup section */
|
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
|
||||||
249021D3217E4FDB00AE95B9 /* Profile */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Profile;
|
|
||||||
};
|
|
||||||
249021D4217E4FDB00AE95B9 /* Profile */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
ENABLE_BITCODE = NO;
|
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
|
||||||
};
|
|
||||||
name = Profile;
|
|
||||||
};
|
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer.RunnerTests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer.RunnerTests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
|
||||||
CODE_SIGN_STYLE = Automatic;
|
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
|
||||||
MARKETING_VERSION = 1.0;
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer.RunnerTests;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
|
||||||
};
|
|
||||||
name = Profile;
|
|
||||||
};
|
|
||||||
97C147031CF9000F007C117D /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_TESTABILITY = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
|
||||||
GCC_DYNAMIC_NO_PIC = NO;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_OPTIMIZATION_LEVEL = 0;
|
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
|
||||||
"DEBUG=1",
|
|
||||||
"$(inherited)",
|
|
||||||
);
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
97C147041CF9000F007C117D /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
buildSettings = {
|
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
|
||||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
|
|
||||||
CLANG_CXX_LIBRARY = "libc++";
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CLANG_ENABLE_OBJC_ARC = YES;
|
|
||||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
|
||||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_COMMA = YES;
|
|
||||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
|
||||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
|
||||||
CLANG_WARN_EMPTY_BODY = YES;
|
|
||||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
|
||||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
|
||||||
CLANG_WARN_INT_CONVERSION = YES;
|
|
||||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
|
||||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
|
||||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
|
||||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
|
||||||
COPY_PHASE_STRIP = NO;
|
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu99;
|
|
||||||
GCC_NO_COMMON_BLOCKS = YES;
|
|
||||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
|
||||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
|
||||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
|
||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
|
||||||
SDKROOT = iphoneos;
|
|
||||||
SUPPORTED_PLATFORMS = iphoneos;
|
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
|
||||||
VALIDATE_PRODUCT = YES;
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
97C147061CF9000F007C117D /* Debug */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
ENABLE_BITCODE = NO;
|
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
|
||||||
};
|
|
||||||
name = Debug;
|
|
||||||
};
|
|
||||||
97C147071CF9000F007C117D /* Release */ = {
|
|
||||||
isa = XCBuildConfiguration;
|
|
||||||
baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
|
|
||||||
buildSettings = {
|
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
|
||||||
CLANG_ENABLE_MODULES = YES;
|
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
|
||||||
ENABLE_BITCODE = NO;
|
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.dialer;
|
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
|
||||||
SWIFT_VERSION = 5.0;
|
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
|
||||||
};
|
|
||||||
name = Release;
|
|
||||||
};
|
|
||||||
/* End XCBuildConfiguration section */
|
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
|
||||||
331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
331C8088294A63A400263BE5 /* Debug */,
|
|
||||||
331C8089294A63A400263BE5 /* Release */,
|
|
||||||
331C808A294A63A400263BE5 /* Profile */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
97C147031CF9000F007C117D /* Debug */,
|
|
||||||
97C147041CF9000F007C117D /* Release */,
|
|
||||||
249021D3217E4FDB00AE95B9 /* Profile */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
|
|
||||||
isa = XCConfigurationList;
|
|
||||||
buildConfigurations = (
|
|
||||||
97C147061CF9000F007C117D /* Debug */,
|
|
||||||
97C147071CF9000F007C117D /* Release */,
|
|
||||||
249021D4217E4FDB00AE95B9 /* Profile */,
|
|
||||||
);
|
|
||||||
defaultConfigurationIsVisible = 0;
|
|
||||||
defaultConfigurationName = Release;
|
|
||||||
};
|
|
||||||
/* End XCConfigurationList section */
|
|
||||||
};
|
|
||||||
rootObject = 97C146E61CF9000F007C117D /* Project object */;
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>PreviewsEnabled</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -1,98 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Scheme
|
|
||||||
LastUpgradeVersion = "1510"
|
|
||||||
version = "1.3">
|
|
||||||
<BuildAction
|
|
||||||
parallelizeBuildables = "YES"
|
|
||||||
buildImplicitDependencies = "YES">
|
|
||||||
<BuildActionEntries>
|
|
||||||
<BuildActionEntry
|
|
||||||
buildForTesting = "YES"
|
|
||||||
buildForRunning = "YES"
|
|
||||||
buildForProfiling = "YES"
|
|
||||||
buildForArchiving = "YES"
|
|
||||||
buildForAnalyzing = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildActionEntry>
|
|
||||||
</BuildActionEntries>
|
|
||||||
</BuildAction>
|
|
||||||
<TestAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
|
||||||
<MacroExpansion>
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</MacroExpansion>
|
|
||||||
<Testables>
|
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "331C8080294A63A400263BE5"
|
|
||||||
BuildableName = "RunnerTests.xctest"
|
|
||||||
BlueprintName = "RunnerTests"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
|
||||||
<LaunchAction
|
|
||||||
buildConfiguration = "Debug"
|
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
|
||||||
launchStyle = "0"
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
ignoresPersistentStateOnLaunch = "NO"
|
|
||||||
debugDocumentVersioning = "YES"
|
|
||||||
debugServiceExtension = "internal"
|
|
||||||
allowLocationSimulation = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</LaunchAction>
|
|
||||||
<ProfileAction
|
|
||||||
buildConfiguration = "Profile"
|
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
|
||||||
savedToolIdentifier = ""
|
|
||||||
useCustomWorkingDirectory = "NO"
|
|
||||||
debugDocumentVersioning = "YES">
|
|
||||||
<BuildableProductRunnable
|
|
||||||
runnableDebuggingMode = "0">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
|
||||||
BuildableName = "Runner.app"
|
|
||||||
BlueprintName = "Runner"
|
|
||||||
ReferencedContainer = "container:Runner.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</BuildableProductRunnable>
|
|
||||||
</ProfileAction>
|
|
||||||
<AnalyzeAction
|
|
||||||
buildConfiguration = "Debug">
|
|
||||||
</AnalyzeAction>
|
|
||||||
<ArchiveAction
|
|
||||||
buildConfiguration = "Release"
|
|
||||||
revealArchiveInOrganizer = "YES">
|
|
||||||
</ArchiveAction>
|
|
||||||
</Scheme>
|
|
7
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "group:Runner.xcodeproj">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>IDEDidComputeMac32BitWarning</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>PreviewsEnabled</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
@ -1,13 +0,0 @@
|
|||||||
import Flutter
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
@main
|
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
|
||||||
override func application(
|
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
||||||
) -> Bool {
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,122 +0,0 @@
|
|||||||
{
|
|
||||||
"images" : [
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-20x20@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-29x29@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-40x40@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "60x60",
|
|
||||||
"idiom" : "iphone",
|
|
||||||
"filename" : "Icon-App-60x60@3x.png",
|
|
||||||
"scale" : "3x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "20x20",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-20x20@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "29x29",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-29x29@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "40x40",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-40x40@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "76x76",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-76x76@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "83.5x83.5",
|
|
||||||
"idiom" : "ipad",
|
|
||||||
"filename" : "Icon-App-83.5x83.5@2x.png",
|
|
||||||
"scale" : "2x"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"size" : "1024x1024",
|
|
||||||
"idiom" : "ios-marketing",
|
|
||||||
"filename" : "Icon-App-1024x1024@1x.png",
|
|
||||||
"scale" : "1x"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"version" : 1,
|
|
||||||
"author" : "xcode"
|
|
||||||
}
|
|
||||||
}
|
|
Before Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 295 B |
Before Width: | Height: | Size: 406 B |
Before Width: | Height: | Size: 450 B |
Before Width: | Height: | Size: 282 B |