diff --git a/.gitignore b/.gitignore
index af6a089f8..ad7fb4ae1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,41 @@
+# Built application files
+*.apk
+*.aar
+*.ap_
+*.aab
+
+# Files for the ART/Dalvik VM
+*.dex
+
+# Java class files
+*.class
+
+# Generated files
+bin/
+gen/
+out/
+# Uncomment the following line in case you need and you don't have the release build type files in your app
+# release/
+
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
+local.properties
+
+# Proguard folder generated by Eclipse
+proguard/
+
+# Log Files
+*.log
+
+# Android Studio Navigation editor temp files
+.navigation/
+
+# Android Studio captures folder
+captures/
+
### IntelliJ IDEA ###
out/
!**/src/main/**/out/
@@ -6,6 +44,52 @@ out/
# IntelliJ
*.iml
.idea/
+.idea/workspace.xml
+.idea/tasks.xml
+.idea/gradle.xml
+.idea/assetWizardSettings.xml
+.idea/dictionaries
+.idea/libraries
+# Android Studio 3 in .gitignore file.
+.idea/caches
+.idea/modules.xml
+# Comment next line if keeping position of elements in Navigation Editor is relevant for you
+.idea/navEditor.xml
+
+# Keystore files
+# Uncomment the following lines if you do not want to check your keystore files in.
+#*.jks
+#*.keystore
+
+# External native build folder generated in Android Studio 2.2 and later
+.externalNativeBuild
+.cxx/
+
+# Google Services (e.g. APIs or Firebase)
+# google-services.json
+
+# Freeline
+freeline.py
+freeline/
+freeline_project_description.json
+
+# fastlane
+fastlane/report.xml
+fastlane/Preview.html
+fastlane/screenshots
+fastlane/test_output
+fastlane/readme.md
+
+# Version control
+vcs.xml
+
+# lint
+lint/intermediates/
+lint/generated/
+lint/outputs/
+lint/tmp/
+# lint/reports/
+
misc.xml
deploymentTargetDropDown.xml
render.experimental.xml
diff --git a/INSTALL_INSTRUCTIONS.txt b/INSTALL_INSTRUCTIONS.txt
new file mode 100644
index 000000000..b2ba886d5
--- /dev/null
+++ b/INSTALL_INSTRUCTIONS.txt
@@ -0,0 +1,106 @@
+========================================
+ИНСТРУКЦИЯ ПО УСТАНОВКЕ И ЗАПУСКУ ПРОЕКТА
+Android Contacts
+========================================
+
+ТРЕБОВАНИЯ:
+-----------
+1. Android Studio (последняя версия)
+2. JDK 17 или выше
+3. Android SDK (API Level 24 или выше)
+4. Настроенный эмулятор Android или физическое устройство
+
+
+ШАГИ ДЛЯ УСТАНОВКИ:
+-------------------
+
+1. УСТАНОВКА ANDROID STUDIO:
+ - Скачайте Android Studio с официального сайта:
+ https://developer.android.com/studio
+ - Установите Android Studio со всеми рекомендуемыми компонентами
+ - При первом запуске установите Android SDK
+
+2. НАСТРОЙКА ПЕРЕМЕННЫХ ОКРУЖЕНИЯ:
+ - Откройте "Панель управления" -> "Система" -> "Дополнительные параметры системы"
+ - Нажмите "Переменные среды"
+ - Добавьте новую системную переменную:
+ Имя: ANDROID_HOME
+ Значение: C:\Users\[Ваше_Имя]\AppData\Local\Android\Sdk
+ (путь может отличаться, проверьте в Android Studio: Tools -> SDK Manager)
+
+3. СОЗДАНИЕ ЭМУЛЯТОРА:
+ - Откройте Android Studio
+ - Tools -> Device Manager (или AVD Manager)
+ - Нажмите "Create Device"
+ - Выберите любое устройство (рекомендуется Pixel 5)
+ - Выберите системный образ (рекомендуется API 33 или выше)
+ - Завершите создание и запустите эмулятор
+
+4. ОТКРЫТИЕ ПРОЕКТА:
+ - Откройте Android Studio
+ - File -> Open
+ - Выберите папку с проектом android-contacts
+ - Дождитесь синхронизации Gradle (может занять несколько минут)
+
+
+ЗАПУСК ПРИЛОЖЕНИЯ:
+------------------
+
+СПОСОБ 1 - Через BAT-файл (РЕКОМЕНДУЕТСЯ):
+ 1. Запустите эмулятор через Android Studio или командой:
+ emulator -avd [имя_вашего_эмулятора]
+
+ 2. Дважды кликните на файл: install_and_run.bat
+
+ 3. Скрипт автоматически:
+ - Проверит окружение
+ - Соберёт проект
+ - Установит приложение на эмулятор
+ - Запустит приложение
+
+СПОСОБ 2 - Через Android Studio:
+ 1. Откройте проект в Android Studio
+ 2. Запустите эмулятор
+ 3. Нажмите кнопку "Run" (зелёный треугольник) или Shift+F10
+ 4. Выберите эмулятор из списка устройств
+
+СПОСОБ 3 - Через командную строку:
+ 1. Запустите эмулятор
+ 2. Откройте командную строку в папке проекта
+ 3. Выполните команды:
+ gradlew.bat clean
+ gradlew.bat installDebug
+ adb shell am start -n com.example.contacts/.presentation.main.MainActivity
+
+
+РЕШЕНИЕ ПРОБЛЕМ:
+----------------
+
+Проблема: "ANDROID_HOME не установлена"
+Решение: Настройте переменную окружения ANDROID_HOME (см. пункт 2 выше)
+
+Проблема: "Эмулятор не запущен"
+Решение: Запустите эмулятор через Android Studio или команду emulator -avd [имя]
+
+Проблема: "Gradle sync failed"
+Решение:
+ - Убедитесь что установлен JDK 17
+ - File -> Invalidate Caches / Restart в Android Studio
+ - Проверьте интернет-соединение
+
+Проблема: "SDK не найден"
+Решение: Откройте Tools -> SDK Manager и установите необходимые компоненты:
+ - Android SDK Platform (API 33 или выше)
+ - Android SDK Build-Tools
+ - Android SDK Platform-Tools
+ - Android Emulator
+
+
+КОНТАКТЫ:
+---------
+При возникновении проблем свяжитесь с разработчиком.
+
+
+========================================
+Дата создания: 27.10.2025
+========================================
diff --git a/README.md b/README.md
index 63be1bfe0..e69de29bb 100644
--- a/README.md
+++ b/README.md
@@ -1 +0,0 @@
-# Пустой репозиторий для работы с Java кодом в Android Studio
diff --git a/add_test_contacts.bat b/add_test_contacts.bat
new file mode 100644
index 000000000..1c92a7657
--- /dev/null
+++ b/add_test_contacts.bat
@@ -0,0 +1,81 @@
+@echo off
+setlocal
+
+echo ========================================
+echo Adding Test Contacts
+echo ========================================
+echo.
+
+REM Check that emulator is connected
+echo Checking device connection...
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" devices
+echo.
+
+REM Define device ID
+for /f "skip=1 tokens=1" %%i in ('"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" devices') do (
+ if "%%i"=="emulator-5554" (
+ set DEVICE_ID=emulator-5554
+ )
+)
+
+if not defined DEVICE_ID (
+ echo Device not found. Make sure emulator is running.
+ pause
+ exit /b 1
+)
+
+echo Found device: %DEVICE_ID%
+echo.
+
+echo Adding 10 test contacts...
+echo.
+
+REM Add 10 test contacts to the Android device
+echo Adding contact: John Smith
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "John Smith" -e phone "+79123456789" -e email "smith@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Jane Doe
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Jane Doe" -e phone "+79123456780" -e email "doe@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Robert Johnson
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Robert Johnson" -e phone "+79123456781" -e email "johnson@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Emily Davis
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Emily Davis" -e phone "+79123456782" -e email "davis@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Michael Wilson
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Michael Wilson" -e phone "+79123456783" -e email "wilson@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Sarah Brown
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Sarah Brown" -e phone "+79123456784" -e email "brown@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: David Taylor
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "David Taylor" -e phone "+79123456785" -e email "taylor@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Lisa Anderson
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Lisa Anderson" -e phone "+79123456786" -e email "anderson@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: James Thomas
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "James Thomas" -e phone "+79123456787" -e email "thomas@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo Adding contact: Jennifer Jackson
+"C:\Users\User\AppData\Local\Android\Sdk\platform-tools\adb.exe" shell am start -a android.intent.action.INSERT -t vnd.android.cursor.dir/contact -e name "Jennifer Jackson" -e phone "+79123456788" -e email "jackson@example.com"
+ping -n 3 127.0.0.1 > nul
+
+echo.
+echo ========================================
+echo Test contacts successfully added!
+echo ========================================
+echo.
+echo Restart the Android Contacts app to view the new contacts.
+echo.
+pause
\ No newline at end of file
diff --git a/add_test_contacts_manual.bat b/add_test_contacts_manual.bat
new file mode 100644
index 000000000..c296cceaf
--- /dev/null
+++ b/add_test_contacts_manual.bat
@@ -0,0 +1,75 @@
+@echo off
+setlocal
+
+echo ========================================
+echo Instructions for Adding Test Contacts
+echo ========================================
+echo.
+echo The automated contact addition via ADB failed because:
+echo 1. The Android contacts database is complex (multiple tables)
+echo 2. The intent command was misinterpreted by the system
+echo.
+echo Please follow these manual steps to add test contacts:
+echo.
+echo 1. Open the "Contacts" app on your Android emulator
+echo 2. Tap the "+" or "Add Contact" button
+echo 3. Add these 10 test contacts one by one:
+echo.
+echo Contact 1:
+echo Name: John Smith
+echo Phone: +79123456789
+echo Email: smith@example.com
+echo.
+echo Contact 2:
+echo Name: Jane Doe
+echo Phone: +79123456780
+echo Email: doe@example.com
+echo.
+echo Contact 3:
+echo Name: Robert Johnson
+echo Phone: +79123456781
+echo Email: johnson@example.com
+echo.
+echo Contact 4:
+echo Name: Emily Davis
+echo Phone: +79123456782
+echo Email: davis@example.com
+echo.
+echo Contact 5:
+echo Name: Michael Wilson
+echo Phone: +79123456783
+echo Email: wilson@example.com
+echo.
+echo Contact 6:
+echo Name: Sarah Brown
+echo Phone: +79123456784
+echo Email: brown@example.com
+echo.
+echo Contact 7:
+echo Name: David Taylor
+echo Phone: +79123456785
+echo Email: taylor@example.com
+echo.
+echo Contact 8:
+echo Name: Lisa Anderson
+echo Phone: +79123456786
+echo Email: anderson@example.com
+echo.
+echo Contact 9:
+echo Name: James Thomas
+echo Phone: +79123456787
+echo Email: thomas@example.com
+echo.
+echo Contact 10:
+echo Name: Jennifer Jackson
+echo Phone: +79123456788
+echo Email: jackson@example.com
+echo.
+echo After adding the contacts, return to the Android Contacts app (ru.yandex.practicum.contacts)
+echo to see the new contacts in the list.
+echo.
+echo If you need to restart the application, run:
+echo adb shell am force-stop ru.yandex.practicum.contacts
+echo adb shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity
+echo.
+pause
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 000000000..42afabfd2
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1 @@
+/build
\ No newline at end of file
diff --git a/app/build.gradle b/app/build.gradle
new file mode 100644
index 000000000..c95c1c8cc
--- /dev/null
+++ b/app/build.gradle
@@ -0,0 +1,47 @@
+plugins {
+ id 'com.android.application'
+ id 'org.jetbrains.kotlin.android'
+}
+
+android {
+ compileSdk 34
+ namespace = "com.example.contacts"
+
+ defaultConfig {
+ applicationId "com.example.contacts"
+ minSdk 24
+ targetSdk 34
+ versionCode 1
+ versionName "1.0"
+
+ testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ }
+ }
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_1_8
+ targetCompatibility JavaVersion.VERSION_1_8
+ }
+ buildFeatures {
+ viewBinding true
+ }
+}
+
+dependencies {
+
+ implementation 'androidx.appcompat:appcompat:1.7.0'
+ implementation 'com.google.android.material:material:1.12.0'
+ implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
+ implementation 'androidx.navigation:navigation-fragment:2.8.3'
+ implementation 'androidx.navigation:navigation-ui:2.8.3'
+ implementation 'com.github.bumptech.glide:glide:4.16.0'
+ annotationProcessor 'com.github.bumptech.glide:compiler:4.16.0'
+ testImplementation 'junit:junit:4.13.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
+ androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1'
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
new file mode 100644
index 000000000..481bb4348
--- /dev/null
+++ b/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
\ No newline at end of file
diff --git a/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java b/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java
new file mode 100644
index 000000000..8988aa556
--- /dev/null
+++ b/app/src/androidTest/java/ru/yandex/practicum/contacts/ExampleInstrumentedTest.java
@@ -0,0 +1,26 @@
+package ru.yandex.practicum.contacts;
+
+import android.content.Context;
+
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import static org.junit.Assert.*;
+
+/**
+ * Instrumented test, which will execute on an Android device.
+ *
+ * @see Testing documentation
+ */
+@RunWith(AndroidJUnit4.class)
+public class ExampleInstrumentedTest {
+ @Test
+ public void useAppContext() {
+ // Context of the app under test.
+ Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ assertEquals("ru.yandex.practicum.contacts", appContext.getPackageName());
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..2b9822095
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png
new file mode 100644
index 000000000..e78269970
Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ
diff --git a/app/src/main/java/com/example/contacts/SplashActivity.java b/app/src/main/java/com/example/contacts/SplashActivity.java
new file mode 100644
index 000000000..a73ec13b9
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/SplashActivity.java
@@ -0,0 +1,80 @@
+package com.example.contacts;
+
+import android.Manifest;
+import android.annotation.SuppressLint;
+import android.content.Intent;
+import android.graphics.drawable.TransitionDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.Settings;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import com.example.contacts.databinding.SplashActivityBinding;
+import com.example.contacts.presentation.main.MainActivity;
+import com.example.contacts.utils.android.ContextUtils;
+
+@SuppressLint("CustomSplashScreen")
+public class SplashActivity extends AppCompatActivity {
+
+ private static final int ANIMATION_TIME = 250;
+
+ private final ActivityResultLauncher requestPermissionLauncher =
+ registerForActivityResult(new ActivityResultContracts.RequestPermission(), this::onPermissionResult);
+
+ private SplashActivityBinding binding;
+
+ @Override
+ protected void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ binding = SplashActivityBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+ binding.settingsButton.setOnClickListener(view -> navigateToSettings());
+
+ if (ContextUtils.hasContactPermissions(this)) {
+ navigateToMain();
+ } else {
+ requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS);
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ super.onStart();
+ if (ContextUtils.hasContactPermissions(this)) {
+ navigateToMain();
+ }
+ }
+
+ private void navigateToMain() {
+ final Intent intent = new Intent(this, MainActivity.class);
+ startActivity(intent);
+ finish();
+ }
+
+ private void showSettings() {
+ final TransitionDrawable drawable = (TransitionDrawable) binding.getRoot().getBackground();
+ drawable.startTransition(ANIMATION_TIME);
+ binding.settingsButton.setVisibility(View.VISIBLE);
+ binding.logo.setVisibility(View.GONE);
+ }
+
+ private void onPermissionResult(Boolean isGranted) {
+ if (isGranted) {
+ navigateToMain();
+ } else {
+ showSettings();
+ }
+ }
+
+ private void navigateToSettings() {
+ final Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
+ final Uri uri = Uri.fromParts("package", getPackageName(), null);
+ intent.setData(uri);
+ startActivity(intent);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/interactor/ContactMerger.java b/app/src/main/java/com/example/contacts/interactor/ContactMerger.java
new file mode 100644
index 000000000..87d07ffa7
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/interactor/ContactMerger.java
@@ -0,0 +1,63 @@
+package com.example.contacts.interactor;
+
+import android.text.TextUtils;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import com.example.contacts.model.Contact;
+import com.example.contacts.model.ContactSource;
+import com.example.contacts.model.MergedContact;
+import com.example.contacts.utils.Constants;
+import com.example.contacts.utils.model.ContactUtils;
+
+public class ContactMerger {
+
+ public List getMergedContacts(Collection contacts, Collection sources) {
+ final Map sourcesMap = sources.stream()
+ .collect(Collectors.toMap(ContactSource::getName, Function.identity(), this::resolveConflicts));
+ final Map> contactsMap = contacts.stream()
+ .filter(contact -> !TextUtils.isEmpty(ContactUtils.getDisplayName(contact)))
+ .collect(Collectors.groupingBy(ContactUtils::getDisplayName));
+ final List mergedContacts = contactsMap.entrySet().stream()
+ .map(entry -> mergeContact(entry, sourcesMap))
+ .collect(Collectors.toList());
+ return Collections.unmodifiableList(mergedContacts);
+ }
+
+ private MergedContact mergeContact(Map.Entry> entry, Map sources) {
+ final List contacts = entry.getValue();
+ final Contact contact = contacts.get(0);
+ final List contactSources = contacts.stream()
+ .map(each -> Objects.requireNonNull(sources.get(each.getSource())).getPublicName())
+ .distinct()
+ .collect(Collectors.toList());
+ return new MergedContact(
+ contact.getId(),
+ contact.getFirstName(),
+ contact.getMiddleName(),
+ contact.getSurname(),
+ ContactUtils.getFirstPhone(contact),
+ ContactUtils.getFirstNormalizedPhone(contact),
+ ContactUtils.getFirstEmail(contact),
+ contactSources,
+ contact.getPhotoUri()
+ );
+ }
+
+ private ContactSource resolveConflicts(ContactSource source1, ContactSource source2) {
+ if (Objects.equals(source1.getType(), Constants.StorageType.GOOGLE)) {
+ return source1;
+ }
+ if (Objects.equals(source2.getType(), Constants.StorageType.GOOGLE)) {
+ return source2;
+ }
+ return source1;
+ }
+
+}
diff --git a/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java b/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java
new file mode 100644
index 000000000..e51e0c583
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/mapper/ContactUiMapper.java
@@ -0,0 +1,30 @@
+package com.example.contacts.mapper;
+
+import android.text.TextUtils;
+
+import com.example.contacts.model.MergedContact;
+import com.example.contacts.presentation.main.ContactUi;
+import com.example.contacts.utils.model.MergedContactUtils;
+import com.example.contacts.utils.model.PhoneUtils;
+
+public class ContactUiMapper {
+
+ public ContactUi map(MergedContact contact) {
+ String displayName = (contact.getFirstName() + " " + contact.getSurname()).trim();
+ String phone = PhoneUtils.format(contact.getPhone());
+ if (TextUtils.isEmpty(displayName)) {
+ if (!TextUtils.isEmpty(phone)) {
+ displayName = phone;
+ phone = "";
+ } else {
+ displayName = contact.getEmail();
+ }
+ }
+ return new ContactUi(
+ displayName,
+ phone,
+ contact.getPhotoUri(),
+ MergedContactUtils.getContactTypes(contact)
+ );
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/Address.java b/app/src/main/java/com/example/contacts/model/Address.java
new file mode 100644
index 000000000..aa617cc78
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/Address.java
@@ -0,0 +1,48 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+public class Address {
+
+ private final String value;
+ private final int type;
+ private final String label;
+
+ public Address(@NonNull String value, int type, @NonNull String label) {
+ this.value = value;
+ this.type = type;
+ this.label = label;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Address address = (Address) o;
+
+ if (type != address.type) return false;
+ if (!value.equals(address.value)) return false;
+ return label.equals(address.label);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = value.hashCode();
+ result = 31 * result + type;
+ result = 31 * result + label.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/Contact.java b/app/src/main/java/com/example/contacts/model/Contact.java
new file mode 100644
index 000000000..c57dee023
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/Contact.java
@@ -0,0 +1,141 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+public class Contact {
+
+ private final int id;
+ private final String prefix;
+ private final String firstName;
+ private final String middleName;
+ private final String surname;
+ private final String suffix;
+ private final String photoUri;
+ private final List phoneNumbers;
+ private final List emails;
+ private final List addresses;
+ private final String source;
+ private final int starred;
+ private final int contactId;
+ private final String thumbnailUri;
+
+ public Contact(int id, @NonNull String prefix, @NonNull String firstName, @NonNull String middleName, @NonNull String surname,
+ @NonNull String suffix, @NonNull String photoUri, @NonNull List phoneNumbers, @NonNull List emails,
+ @NonNull List addresses, @NonNull String source, int starred, int contactId, @NonNull String thumbnailUri
+ ) {
+ this.id = id;
+ this.prefix = prefix;
+ this.firstName = firstName;
+ this.middleName = middleName;
+ this.surname = surname;
+ this.suffix = suffix;
+ this.photoUri = photoUri;
+ this.phoneNumbers = phoneNumbers;
+ this.emails = emails;
+ this.addresses = addresses;
+ this.source = source;
+ this.starred = starred;
+ this.contactId = contactId;
+ this.thumbnailUri = thumbnailUri;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getPrefix() {
+ return prefix;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public String getMiddleName() {
+ return middleName;
+ }
+
+ public String getSurname() {
+ return surname;
+ }
+
+ public String getSuffix() {
+ return suffix;
+ }
+
+ public String getPhotoUri() {
+ return photoUri;
+ }
+
+ public List getPhoneNumbers() {
+ return phoneNumbers;
+ }
+
+ public List getEmails() {
+ return emails;
+ }
+
+ public List getAddresses() {
+ return addresses;
+ }
+
+ public String getSource() {
+ return source;
+ }
+
+ public int getStarred() {
+ return starred;
+ }
+
+ public int getContactId() {
+ return contactId;
+ }
+
+ public String getThumbnailUri() {
+ return thumbnailUri;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Contact contact = (Contact) o;
+
+ if (id != contact.id) return false;
+ if (starred != contact.starred) return false;
+ if (contactId != contact.contactId) return false;
+ if (!prefix.equals(contact.prefix)) return false;
+ if (!firstName.equals(contact.firstName)) return false;
+ if (!middleName.equals(contact.middleName)) return false;
+ if (!surname.equals(contact.surname)) return false;
+ if (!suffix.equals(contact.suffix)) return false;
+ if (!photoUri.equals(contact.photoUri)) return false;
+ if (!phoneNumbers.equals(contact.phoneNumbers)) return false;
+ if (!emails.equals(contact.emails)) return false;
+ if (!addresses.equals(contact.addresses)) return false;
+ if (!source.equals(contact.source)) return false;
+ return thumbnailUri.equals(contact.thumbnailUri);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = id;
+ result = 31 * result + prefix.hashCode();
+ result = 31 * result + firstName.hashCode();
+ result = 31 * result + middleName.hashCode();
+ result = 31 * result + surname.hashCode();
+ result = 31 * result + suffix.hashCode();
+ result = 31 * result + photoUri.hashCode();
+ result = 31 * result + phoneNumbers.hashCode();
+ result = 31 * result + emails.hashCode();
+ result = 31 * result + addresses.hashCode();
+ result = 31 * result + source.hashCode();
+ result = 31 * result + starred;
+ result = 31 * result + contactId;
+ result = 31 * result + thumbnailUri.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/ContactSource.java b/app/src/main/java/com/example/contacts/model/ContactSource.java
new file mode 100644
index 000000000..1b762c5c7
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/ContactSource.java
@@ -0,0 +1,48 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+public class ContactSource {
+
+ private final String name;
+ private final String type;
+ private final String publicName;
+
+ public ContactSource(@NonNull String name, @NonNull String type, @NonNull String publicName) {
+ this.name = name;
+ this.type = type;
+ this.publicName = publicName;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public String getPublicName() {
+ return publicName;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ContactSource that = (ContactSource) o;
+
+ if (!name.equals(that.name)) return false;
+ if (!type.equals(that.type)) return false;
+ return publicName.equals(that.publicName);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name.hashCode();
+ result = 31 * result + type.hashCode();
+ result = 31 * result + publicName.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/ContactType.java b/app/src/main/java/com/example/contacts/model/ContactType.java
new file mode 100644
index 000000000..a0df5816e
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/ContactType.java
@@ -0,0 +1,11 @@
+package com.example.contacts.model;
+
+public enum ContactType {
+ TELEGRAM,
+ WHATS_APP,
+ VIBER,
+ SIGNAL,
+ THREEMA,
+ PHONE,
+ EMAIL
+}
diff --git a/app/src/main/java/com/example/contacts/model/Email.java b/app/src/main/java/com/example/contacts/model/Email.java
new file mode 100644
index 000000000..e760d8f15
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/Email.java
@@ -0,0 +1,48 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+public class Email {
+
+ private final String value;
+ private final int type;
+ private final String label;
+
+ public Email(@NonNull String value, int type, @NonNull String label) {
+ this.value = value;
+ this.type = type;
+ this.label = label;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Email email = (Email) o;
+
+ if (type != email.type) return false;
+ if (!value.equals(email.value)) return false;
+ return label.equals(email.label);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = value.hashCode();
+ result = 31 * result + type;
+ result = 31 * result + label.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/MergedContact.java b/app/src/main/java/com/example/contacts/model/MergedContact.java
new file mode 100644
index 000000000..f191ecf6d
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/MergedContact.java
@@ -0,0 +1,100 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+public class MergedContact {
+
+ private final int id;
+ private final String firstName;
+ private final String middleName;
+ private final String surname;
+ private final String phone;
+ private final String normalizedNumber;
+ private final String email;
+ private final List contactTypes;
+ private final String photoUri;
+
+ public MergedContact(int id, @NonNull String firstName, @NonNull String middleName, @NonNull String surname, @NonNull String phone,
+ @NonNull String normalizedNumber, @NonNull String email, @NonNull List contactTypes, @NonNull String photoUri
+ ) {
+ this.id = id;
+ this.firstName = firstName;
+ this.middleName = middleName;
+ this.surname = surname;
+ this.phone = phone;
+ this.normalizedNumber = normalizedNumber;
+ this.email = email;
+ this.contactTypes = contactTypes;
+ this.photoUri = photoUri;
+ }
+
+ public int getId() {
+ return id;
+ }
+
+ public String getFirstName() {
+ return firstName;
+ }
+
+ public String getMiddleName() {
+ return middleName;
+ }
+
+ public String getSurname() {
+ return surname;
+ }
+
+ public String getPhone() {
+ return phone;
+ }
+
+ public String getNormalizedNumber() {
+ return normalizedNumber;
+ }
+
+ public String getEmail() {
+ return email;
+ }
+
+ public List getContactTypes() {
+ return contactTypes;
+ }
+
+ public String getPhotoUri() {
+ return photoUri;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ MergedContact that = (MergedContact) o;
+
+ if (id != that.id) return false;
+ if (!firstName.equals(that.firstName)) return false;
+ if (!middleName.equals(that.middleName)) return false;
+ if (!surname.equals(that.surname)) return false;
+ if (!phone.equals(that.phone)) return false;
+ if (!normalizedNumber.equals(that.normalizedNumber)) return false;
+ if (!email.equals(that.email)) return false;
+ if (!contactTypes.equals(that.contactTypes)) return false;
+ return photoUri.equals(that.photoUri);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = id;
+ result = 31 * result + firstName.hashCode();
+ result = 31 * result + middleName.hashCode();
+ result = 31 * result + surname.hashCode();
+ result = 31 * result + phone.hashCode();
+ result = 31 * result + normalizedNumber.hashCode();
+ result = 31 * result + email.hashCode();
+ result = 31 * result + contactTypes.hashCode();
+ result = 31 * result + photoUri.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/model/PhoneNumber.java b/app/src/main/java/com/example/contacts/model/PhoneNumber.java
new file mode 100644
index 000000000..22e26d1be
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/model/PhoneNumber.java
@@ -0,0 +1,56 @@
+package com.example.contacts.model;
+
+import androidx.annotation.NonNull;
+
+public class PhoneNumber {
+
+ private final String value;
+ private final int type;
+ private final String label;
+ private final String normalizedNumber;
+
+ public PhoneNumber(@NonNull String value, int type, @NonNull String label, @NonNull String normalizedNumber) {
+ this.value = value;
+ this.type = type;
+ this.label = label;
+ this.normalizedNumber = normalizedNumber;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public int getType() {
+ return type;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public String getNormalizedNumber() {
+ return normalizedNumber;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ PhoneNumber that = (PhoneNumber) o;
+
+ if (type != that.type) return false;
+ if (!value.equals(that.value)) return false;
+ if (!label.equals(that.label)) return false;
+ return normalizedNumber.equals(that.normalizedNumber);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = value.hashCode();
+ result = 31 * result + type;
+ result = 31 * result + label.hashCode();
+ result = 31 * result + normalizedNumber.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java
new file mode 100644
index 000000000..59df0eefa
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetDialogFragment.java
@@ -0,0 +1,65 @@
+package com.example.contacts.presentation.base;
+
+import android.app.Dialog;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.google.android.material.bottomsheet.BottomSheetBehavior;
+import com.google.android.material.bottomsheet.BottomSheetDialog;
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
+
+import com.example.contacts.databinding.FragmentBottomSheetBinding;
+
+public abstract class BaseBottomSheetDialogFragment extends BottomSheetDialogFragment {
+
+ private final Class viewModelClass;
+
+ protected FragmentBottomSheetBinding binding;
+ protected T viewModel;
+
+ public BaseBottomSheetDialogFragment(Class viewModelClass) {
+ this.viewModelClass = viewModelClass;
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ binding = FragmentBottomSheetBinding.inflate(inflater, container, false);
+ return binding.getRoot();
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ final Dialog dialog = super.onCreateDialog(savedInstanceState);
+ dialog.setOnShowListener(dialogInterface -> {
+ final BottomSheetDialog bottomSheetDialog = (BottomSheetDialog) dialogInterface;
+ final View bottomSheet = bottomSheetDialog.findViewById(com.google.android.material.R.id.design_bottom_sheet);
+ if (bottomSheet != null) {
+ final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(bottomSheet);
+ bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
+ }
+ });
+ return dialog;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ viewModel = new ViewModelProvider(this).get(viewModelClass);
+ binding.applyButton.setOnClickListener(v -> viewModel.onApplyClick());
+ binding.resetButton.setOnClickListener(v -> viewModel.onResetClick());
+ }
+
+ @Override
+ public void onDestroy() {
+ binding = null;
+ super.onDestroy();
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java
new file mode 100644
index 000000000..60e98511d
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/base/BaseBottomSheetViewModel.java
@@ -0,0 +1,8 @@
+package com.example.contacts.presentation.base;
+
+import androidx.lifecycle.ViewModel;
+
+public abstract class BaseBottomSheetViewModel extends ViewModel {
+ abstract public void onApplyClick();
+ abstract public void onResetClick();
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java b/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java
new file mode 100644
index 000000000..f207cd943
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/base/BaseListDiffCallback.java
@@ -0,0 +1,17 @@
+package com.example.contacts.presentation.base;
+
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.DiffUtil;
+
+public class BaseListDiffCallback> extends DiffUtil.ItemCallback {
+
+ @Override
+ public boolean areItemsTheSame(@NonNull T oldItem, @NonNull T newItem) {
+ return oldItem.theSameAs(newItem);
+ }
+
+ @Override
+ public boolean areContentsTheSame(@NonNull T oldItem, @NonNull T newItem) {
+ return oldItem.equals(newItem);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java b/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java
new file mode 100644
index 000000000..25b299e61
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/base/ListDiffInterface.java
@@ -0,0 +1,6 @@
+package com.example.contacts.presentation.base;
+
+public interface ListDiffInterface {
+ boolean theSameAs(T other);
+ boolean equals(Object other);
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java
new file mode 100644
index 000000000..c1b8919d1
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeAdapter.java
@@ -0,0 +1,89 @@
+package com.example.contacts.presentation.filter;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.AdapterListUpdateCallback;
+import androidx.recyclerview.widget.AsyncDifferConfig;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import com.example.contacts.databinding.ItemFilterBinding;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.base.BaseListDiffCallback;
+import com.example.contacts.presentation.filter.model.FilterContactType;
+import com.example.contacts.presentation.filter.model.FilterContactTypeUi;
+import com.example.contacts.utils.model.ContactTypeUtils;
+import com.example.contacts.utils.model.FilterContactTypeUtils;
+
+public class FilterContactTypeAdapter extends RecyclerView.Adapter {
+
+ private final AsyncListDiffer differ = new AsyncListDiffer<>(
+ new AdapterListUpdateCallback(this),
+ new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build()
+ );
+
+ private final Consumer clickListener;
+
+ public FilterContactTypeAdapter(Consumer clickListener) {
+ this.clickListener = clickListener;
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final ItemFilterBinding binding = ItemFilterBinding.inflate(inflater, parent, false);
+ return new ViewHolder(binding, clickListener);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.bind(differ.getCurrentList().get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return differ.getCurrentList().size();
+ }
+
+ public void setItems(List items) {
+ differ.submitList(items);
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemFilterBinding binding;
+
+ private FilterContactTypeUi data;
+
+ public ViewHolder(@NonNull ItemFilterBinding binding, Consumer clickListener) {
+ super(binding.getRoot());
+ this.binding = binding;
+ this.binding.getRoot().setOnClickListener(v -> clickListener.accept(data));
+ this.binding.selected.setOnClickListener(v -> clickListener.accept(data));
+ }
+
+ public void bind(FilterContactTypeUi data) {
+ this.data = data;
+ final int sortResId = FilterContactTypeUtils.getStringRes(data.getContactType());
+ binding.text.setText(sortResId);
+ binding.selected.setChecked(data.isSelected());
+ if (data.getContactType() == FilterContactType.ALL){
+ binding.logo.setVisibility(View.GONE);
+ } else {
+ final ContactType contactType = FilterContactTypeUtils.toContactType(data.getContactType());
+ final int iconRes = ContactTypeUtils.getIconRes(contactType);
+ binding.logo.setVisibility(View.VISIBLE);
+ binding.logo.setImageResource(iconRes);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java
new file mode 100644
index 000000000..d9c92914e
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeDialogFragment.java
@@ -0,0 +1,84 @@
+package com.example.contacts.presentation.filter;
+
+import android.os.Bundle;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.example.contacts.R;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.base.BaseBottomSheetDialogFragment;
+import com.example.contacts.presentation.filter.model.FilterContactTypeUi;
+import com.example.contacts.ui.widget.DividerItemDecoration;
+
+public class FilterContactTypeDialogFragment extends BaseBottomSheetDialogFragment {
+
+ public static final String REQUEST_KEY = "REQUEST_KEY_FILTER";
+ public static final String ARG_SELECTED_FILTER_CONTACT_TYPE = "ARG_SELECTED_FILTER_CONTACT_TYPE";
+
+ private FilterContactTypeAdapter adapter;
+
+ private FilterContactTypeDialogFragment() {
+ super(FilterContactTypeViewModel.class);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ iniViewModel();
+ adapter = new FilterContactTypeAdapter(viewModel::onFilterTypeItemClick);
+ binding.recycler.setAdapter(adapter);
+
+ final DividerItemDecoration decoration = new DividerItemDecoration(
+ requireActivity(), R.drawable.item_decoration_16dp, R.drawable.item_decoration_72dp, DividerItemDecoration.VERTICAL
+ );
+ binding.recycler.addItemDecoration(decoration);
+
+ viewModel.getFilterContactTypesLiveDate().observe(this, this::updateFilterContactTypes);
+ viewModel.getUiStateLiveDate().observe(this, this::updateState);
+ }
+
+ private void iniViewModel() {
+ final Set defaultFilterContactTypes = from(getArguments());
+ viewModel.init(defaultFilterContactTypes);
+ }
+
+ private void updateFilterContactTypes(List filterTypes) {
+ adapter.setItems(filterTypes);
+ }
+
+ private void updateState(FilterContactTypeViewModel.UiState state) {
+ binding.applyButton.setEnabled(state.isApplyEnable);
+
+ if (!state.newSelectedContactTypes.isEmpty()) {
+ getParentFragmentManager().setFragmentResult(REQUEST_KEY, createBundle(state.newSelectedContactTypes));
+ dismiss();
+ }
+ }
+
+ public static FilterContactTypeDialogFragment newInstance(Set selectedContactTypes) {
+ final FilterContactTypeDialogFragment fragment = new FilterContactTypeDialogFragment();
+ fragment.setArguments(createBundle(selectedContactTypes));
+ return fragment;
+ }
+
+ @SuppressWarnings("unchecked")
+ public static Set from(@Nullable Bundle bundle) {
+ if (bundle == null) {
+ return Collections.emptySet();
+ }
+ return (Set) bundle.getSerializable(ARG_SELECTED_FILTER_CONTACT_TYPE);
+ }
+
+ private static Bundle createBundle(Set contactTypes) {
+ final Bundle bundle = new Bundle();
+ bundle.putSerializable(ARG_SELECTED_FILTER_CONTACT_TYPE, new HashSet<>(contactTypes));
+ return bundle;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java
new file mode 100644
index 000000000..007fefb2b
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/filter/FilterContactTypeViewModel.java
@@ -0,0 +1,103 @@
+package com.example.contacts.presentation.filter;
+
+import androidx.lifecycle.MutableLiveData;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.base.BaseBottomSheetViewModel;
+import com.example.contacts.presentation.filter.model.FilterContactType;
+import com.example.contacts.presentation.filter.model.FilterContactTypeUi;
+import com.example.contacts.utils.model.ContactTypeUtils;
+import com.example.contacts.utils.model.FilterContactTypeUtils;
+
+public class FilterContactTypeViewModel extends BaseBottomSheetViewModel {
+
+ private final UiState uiState = new UiState();
+ private final MutableLiveData> filterContactTypesLiveDate = new MutableLiveData<>();
+ private final MutableLiveData uiStateLiveDate = new MutableLiveData<>();
+
+ private Set defaultFilterContactTypes;
+ private Set selectedFilterContactTypes;
+
+ public void init(Set defaultFilterContactTypes) {
+ this.defaultFilterContactTypes = new HashSet<>(defaultFilterContactTypes);
+ this.selectedFilterContactTypes = new HashSet<>(defaultFilterContactTypes);
+ updateFilterContactTypes();
+ updateUiState();
+ }
+
+ public void onFilterTypeItemClick(FilterContactTypeUi filterContactType) {
+ updateSelectedContactTypes(filterContactType.getContactType());
+ updateFilterContactTypes();
+ updateUiState();
+ }
+
+ @Override
+ public void onApplyClick() {
+ uiState.newSelectedContactTypes = selectedFilterContactTypes;
+ updateUiState();
+ }
+
+ @Override
+ public void onResetClick() {
+ selectedFilterContactTypes = new HashSet<>(defaultFilterContactTypes);
+ updateFilterContactTypes();
+ updateUiState();
+ }
+
+ public MutableLiveData> getFilterContactTypesLiveDate() {
+ return filterContactTypesLiveDate;
+ }
+
+ public MutableLiveData getUiStateLiveDate() {
+ return uiStateLiveDate;
+ }
+
+ private void updateFilterContactTypes() {
+ final List filterContactTypesUi = new ArrayList<>();
+ final boolean allSelected = selectedFilterContactTypes.size() == ContactType.values().length;
+ filterContactTypesUi.add(new FilterContactTypeUi(FilterContactType.ALL, allSelected));
+ final List collect = Arrays.stream(ContactType.values())
+ .map(contactType -> new FilterContactTypeUi(
+ ContactTypeUtils.toFilterContactType(contactType),
+ selectedFilterContactTypes.contains(contactType)
+ ))
+ .collect(Collectors.toList());
+ filterContactTypesUi.addAll(collect);
+ filterContactTypesLiveDate.setValue(filterContactTypesUi);
+ }
+
+ private void updateUiState() {
+ uiState.isApplyEnable = !defaultFilterContactTypes.equals(selectedFilterContactTypes) && !selectedFilterContactTypes.isEmpty();
+ uiStateLiveDate.setValue(uiState);
+ }
+
+ private void updateSelectedContactTypes(FilterContactType type) {
+ if (type == FilterContactType.ALL) {
+ if (selectedFilterContactTypes.size() == ContactType.values().length) {
+ selectedFilterContactTypes.clear();
+ } else {
+ selectedFilterContactTypes.addAll(Arrays.asList(ContactType.values()));
+ }
+ return;
+ }
+ final ContactType contactType = FilterContactTypeUtils.toContactType(type);
+ if (selectedFilterContactTypes.contains(contactType)) {
+ selectedFilterContactTypes.remove(contactType);
+ } else {
+ selectedFilterContactTypes.add(contactType);
+ }
+ }
+
+ static class UiState {
+ public boolean isApplyEnable = false;
+ public Set newSelectedContactTypes = Collections.emptySet();
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java
new file mode 100644
index 000000000..768de8b76
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactType.java
@@ -0,0 +1,12 @@
+package com.example.contacts.presentation.filter.model;
+
+public enum FilterContactType {
+ ALL,
+ TELEGRAM,
+ WHATS_APP,
+ VIBER,
+ SIGNAL,
+ THREEMA,
+ PHONE,
+ EMAIL
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java
new file mode 100644
index 000000000..8114724d0
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/filter/model/FilterContactTypeUi.java
@@ -0,0 +1,47 @@
+package com.example.contacts.presentation.filter.model;
+
+import androidx.annotation.NonNull;
+
+import com.example.contacts.presentation.base.ListDiffInterface;
+
+public class FilterContactTypeUi implements ListDiffInterface {
+
+ private final FilterContactType contactType;
+ private final boolean selected;
+
+ public FilterContactTypeUi(@NonNull FilterContactType contactType, boolean selected) {
+ this.contactType = contactType;
+ this.selected = selected;
+ }
+
+ public FilterContactType getContactType() {
+ return contactType;
+ }
+
+ public boolean isSelected() {
+ return selected;
+ }
+
+ @Override
+ public boolean theSameAs(FilterContactTypeUi other) {
+ return this.contactType == other.contactType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ FilterContactTypeUi that = (FilterContactTypeUi) o;
+
+ if (selected != that.selected) return false;
+ return contactType == that.contactType;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = contactType.hashCode();
+ result = 31 * result + (selected ? 1 : 0);
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java b/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java
new file mode 100644
index 000000000..129a969b9
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/ContactAdapter.java
@@ -0,0 +1,96 @@
+package com.example.contacts.presentation.main;
+
+import android.content.Context;
+import android.graphics.drawable.Drawable;
+import android.text.TextUtils;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.recyclerview.widget.AdapterListUpdateCallback;
+import androidx.recyclerview.widget.AsyncDifferConfig;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.bumptech.glide.Glide;
+
+import java.util.List;
+import java.util.Objects;
+
+import com.example.contacts.R;
+import com.example.contacts.databinding.ItemContactBinding;
+import com.example.contacts.presentation.base.BaseListDiffCallback;
+
+public class ContactAdapter extends RecyclerView.Adapter {
+
+ private final AsyncListDiffer differ = new AsyncListDiffer<>(
+ new AdapterListUpdateCallback(this),
+ new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build()
+ );
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final ItemContactBinding binding = ItemContactBinding.inflate(inflater, parent, false);
+ return new ViewHolder(binding);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.bind(differ.getCurrentList().get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return differ.getCurrentList().size();
+ }
+
+ public void setItems(List items) {
+ differ.submitList(items);
+ }
+
+ public void setItems(List items, @NonNull Runnable callback) {
+ differ.submitList(items, callback);
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemContactBinding binding;
+
+ public ViewHolder(@NonNull ItemContactBinding binding) {
+ super(binding.getRoot());
+ this.binding = binding;
+ binding.getRoot().setOnClickListener(view -> {
+ });
+ }
+
+ public void bind(ContactUi contact) {
+ binding.name.setText(contact.getName());
+ loadAvatar(contact);
+
+ final int phoneVisibility = TextUtils.isEmpty(contact.getPhone()) ? View.GONE : View.VISIBLE;
+ binding.phone.setText(contact.getPhone());
+ binding.phone.setVisibility(phoneVisibility);
+
+ binding.contactType.setData(contact.getTypes());
+ }
+
+ private void loadAvatar(ContactUi contact) {
+ final Context context = binding.contactPhoto.getContext();
+ final Drawable drawable = Objects.requireNonNull(ContextCompat.getDrawable(context, R.drawable.ic_avatar));
+ drawable.setTint(ContextCompat.getColor(context, R.color.color_light_grey));
+ Glide.with(binding.contactPhoto)
+ .load(contact.getPhoto())
+ .circleCrop()
+ .placeholder(drawable)
+ .fallback(drawable)
+ .error(drawable)
+ .into(binding.contactPhoto);
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java b/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java
new file mode 100644
index 000000000..597d820d7
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/ContactUi.java
@@ -0,0 +1,71 @@
+package com.example.contacts.presentation.main;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.base.ListDiffInterface;
+
+public class ContactUi implements ListDiffInterface {
+
+ private final String name;
+ private final String phone;
+ private final String photo;
+ private final List types;
+
+ public ContactUi(
+ @NonNull String name,
+ @NonNull String phone,
+ @NonNull String photo,
+ @NonNull List types
+ ) {
+ this.name = name;
+ this.phone = phone;
+ this.photo = photo;
+ this.types = types;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getPhone() {
+ return phone;
+ }
+
+ public String getPhoto() {
+ return photo;
+ }
+
+ public List getTypes() {
+ return types;
+ }
+
+ @Override
+ public boolean theSameAs(ContactUi other) {
+ return this.hashCode() == other.hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ ContactUi contact = (ContactUi) o;
+
+ if (!name.equals(contact.name)) return false;
+ if (!phone.equals(contact.phone)) return false;
+ if (!photo.equals(contact.photo)) return false;
+ return types.equals(contact.types);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = name.hashCode();
+ result = 31 * result + phone.hashCode();
+ result = 31 * result + photo.hashCode();
+ result = 31 * result + types.hashCode();
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java b/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java
new file mode 100644
index 000000000..440a7bb0d
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/MainActivity.java
@@ -0,0 +1,201 @@
+package com.example.contacts.presentation.main;
+
+import android.annotation.SuppressLint;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.Toast;
+
+import androidx.annotation.IdRes;
+import androidx.annotation.OptIn;
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.ContextCompat;
+import androidx.lifecycle.ViewModelProvider;
+
+import com.google.android.material.badge.BadgeDrawable;
+import com.google.android.material.badge.BadgeUtils;
+import com.google.android.material.badge.ExperimentalBadgeUtils;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import com.example.contacts.R;
+import com.example.contacts.databinding.ActivityMainBinding;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.filter.FilterContactTypeDialogFragment;
+import com.example.contacts.presentation.sort.SortDialogFragment;
+import com.example.contacts.presentation.main.model.MenuClick;
+import com.example.contacts.presentation.sort.model.SortType;
+import com.example.contacts.ui.widget.DividerItemDecoration;
+import com.example.contacts.utils.widget.EditTextUtils;
+
+@SuppressLint("UnsafeExperimentalUsageError")
+public class MainActivity extends AppCompatActivity {
+
+ public static final String SORT_TAG = "SORT_TAG";
+ public static final String FILTER_TAG = "FILTER_TAG";
+
+ private ActivityMainBinding binding;
+ private MainViewModel viewModel;
+ private ContactAdapter adapter;
+
+ private final Map badges = new HashMap<>();
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ binding = ActivityMainBinding.inflate(getLayoutInflater());
+ setContentView(binding.getRoot());
+ setSupportActionBar(binding.toolbar);
+ binding.toolbar.setTitleTextAppearance(this, R.style.Toolbar_Title);
+
+ adapter = new ContactAdapter();
+ binding.recycler.setAdapter(adapter);
+
+ final DividerItemDecoration decoration = new DividerItemDecoration(this, R.drawable.item_decoration_72dp, DividerItemDecoration.VERTICAL);
+ binding.recycler.addItemDecoration(decoration);
+
+ viewModel = new ViewModelProvider(this).get(MainViewModel.class);
+ viewModel.getContactsLiveDate().observe(this, this::updateContacts);
+ viewModel.getUiStateLiveDate().observe(this, this::updateUiState);
+
+ createBadges();
+ EditTextUtils.addTextListener(binding.searchLayout.searchText, query -> viewModel.updateSearchText(query.toString()));
+ EditTextUtils.debounce(binding.searchLayout.searchText, query -> viewModel.search());
+ binding.searchLayout.resetButton.setOnClickListener(view -> clearSearch());
+
+ getSupportFragmentManager().setFragmentResultListener(SortDialogFragment.REQUEST_KEY, this, (requestKey, result) -> {
+ final SortType newSortType = SortDialogFragment.from(result);
+ viewModel.updateSortType(newSortType);
+ });
+
+ getSupportFragmentManager().setFragmentResultListener(FilterContactTypeDialogFragment.REQUEST_KEY, this, (requestKey, result) -> {
+ final Set newFilterContactTypes = FilterContactTypeDialogFragment.from(result);
+ viewModel.updateFilterContactTypes(newFilterContactTypes);
+ });
+ }
+
+ @Override
+ public boolean onCreateOptionsMenu(Menu menu) {
+ getMenuInflater().inflate(R.menu.menu_main, menu);
+ attachBadges();
+ return true;
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ int id = item.getItemId();
+ if (id == R.id.menu_sort) {
+ viewModel.onMenuClick(MenuClick.SORT);
+ return true;
+ }
+ if (id == R.id.menu_filter) {
+ viewModel.onMenuClick(MenuClick.FILTER);
+ return true;
+ }
+ if (id == R.id.menu_search) {
+ viewModel.onMenuClick(MenuClick.SEARCH);
+ return true;
+ }
+
+ return super.onOptionsItemSelected(item);
+ }
+
+ private void showSortDialog(SortType sortType) {
+ SortDialogFragment.newInstance(sortType).show(getSupportFragmentManager(), SORT_TAG);
+ }
+
+ private void showFilterContactTypeDialog(Set contactTypes) {
+ FilterContactTypeDialogFragment.newInstance(contactTypes).show(getSupportFragmentManager(), FILTER_TAG);
+ }
+
+ @Override
+ public void onBackPressed() {
+ viewModel.onBackPressed();
+ super.onBackPressed();
+ }
+
+ private void updateContacts(List contacts) {
+ adapter.setItems(contacts, () -> binding.recycler.scrollToPosition(0));
+ if (contacts.size() > 0) {
+ binding.recycler.setVisibility(View.VISIBLE);
+ binding.nothingFound.setVisibility(View.GONE);
+ } else {
+ binding.recycler.setVisibility(View.GONE);
+ binding.nothingFound.setVisibility(View.VISIBLE);
+ }
+ }
+
+ private void updateUiState(MainViewModel.UiState uiState) {
+ final Boolean finishActivity = uiState.actions.finishActivity.data;
+ if (finishActivity != null && finishActivity) {
+ finish();
+ return;
+ }
+ binding.searchLayout.getRoot().setVisibility(uiState.searchVisibility ? View.VISIBLE : View.GONE);
+ binding.searchLayout.resetButton.setVisibility(uiState.resetSearchButtonVisibility ? View.VISIBLE : View.GONE);
+ if (uiState.actions.showSortTypeDialog.data != null) {
+ showSortDialog(uiState.actions.showSortTypeDialog.data);
+ }
+ final Set filterContactTypes = uiState.actions.showFilterContactTypeDialog.data;
+ if (filterContactTypes != null && filterContactTypes.size() > 0) {
+ showFilterContactTypeDialog(filterContactTypes);
+ }
+ updateBadges(uiState);
+ }
+
+ private void updateBadges(MainViewModel.UiState uiState) {
+ updateBadge(uiState.menuBadges.sort, R.id.menu_sort);
+ updateBadge(uiState.menuBadges.filter, R.id.menu_filter);
+ updateBadge(uiState.menuBadges.search, R.id.menu_search);
+ }
+
+ private void updateBadge(MainViewModel.UiState.MenuBadge badge, @IdRes int menuItemId) {
+ final BadgeDrawable drawable = Objects.requireNonNull(badges.get(menuItemId));
+ if (badge != null) {
+ drawable.setVisible(true);
+ if (badge.value > 0) {
+ drawable.setNumber(badge.value);
+ } else {
+ drawable.clearNumber();
+ }
+ } else {
+ drawable.setVisible(false);
+ }
+ }
+
+ private void createBadges() {
+ badges.put(R.id.menu_sort, createBadge());
+ badges.put(R.id.menu_filter, createBadge());
+ badges.put(R.id.menu_search, createBadge());
+ }
+
+ @OptIn(markerClass = ExperimentalBadgeUtils.class)
+ private void attachBadges(){
+ for (Map.Entry entry : badges.entrySet()) {
+ BadgeUtils.attachBadgeDrawable(entry.getValue(), binding.toolbar, entry.getKey());
+ }
+ }
+
+ private BadgeDrawable createBadge() {
+ final BadgeDrawable drawable = BadgeDrawable.create(this);
+ drawable.setBackgroundColor(ContextCompat.getColor(this, R.color.color_red));
+ drawable.setVisible(false);
+ return drawable;
+ }
+
+ private void clearSearch() {
+ binding.searchLayout.searchText.setText("");
+ viewModel.search();
+ }
+
+ private void toast(@StringRes int res) {
+ Toast.makeText(this, res, Toast.LENGTH_SHORT).show();
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainState.java b/app/src/main/java/com/example/contacts/presentation/main/MainState.java
new file mode 100644
index 000000000..121a86cc4
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/MainState.java
@@ -0,0 +1,70 @@
+package com.example.contacts.presentation.main;
+
+import androidx.annotation.NonNull;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.example.contacts.model.ContactType;
+import com.example.contacts.model.MergedContact;
+import com.example.contacts.presentation.sort.model.SortType;
+
+public class MainState {
+
+ private final SortType defaultSortType = SortType.BY_NAME;
+ private final Set defaultContactTypes = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(ContactType.values())));
+
+ private List allContacts = Collections.emptyList();
+ private SortType sortType = defaultSortType;
+ private Set contactTypes = new HashSet<>(defaultContactTypes);
+ private String query = "";
+
+ @NonNull
+ public List getAllContacts() {
+ return allContacts;
+ }
+
+ public void setAllContacts(@NonNull List allContacts) {
+ this.allContacts = allContacts;
+ }
+
+ @NonNull
+ public SortType getDefaultSortType() {
+ return defaultSortType;
+ }
+
+ @NonNull
+ public SortType getSortType() {
+ return sortType;
+ }
+
+ public void setSortType(@NonNull SortType sortType) {
+ this.sortType = sortType;
+ }
+
+ @NonNull
+ public Set getDefaultContactTypes() {
+ return defaultContactTypes;
+ }
+
+ @NonNull
+ public Set getContactTypes() {
+ return contactTypes;
+ }
+
+ public void setContactTypes(@NonNull Set contactTypes) {
+ this.contactTypes = contactTypes;
+ }
+
+ @NonNull
+ public String getQuery() {
+ return query;
+ }
+
+ public void setQuery(@NonNull String query) {
+ this.query = query;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java b/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java
new file mode 100644
index 000000000..11f42b6ea
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/MainViewModel.java
@@ -0,0 +1,290 @@
+package com.example.contacts.presentation.main;
+
+import android.app.Application;
+import android.text.TextUtils;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+import com.example.contacts.model.Contact;
+import com.example.contacts.model.ContactSource;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.model.MergedContact;
+import com.example.contacts.interactor.ContactMerger;
+import com.example.contacts.repository.ContactRepository;
+import com.example.contacts.repository.ContactSourceRepository;
+import com.example.contacts.mapper.ContactUiMapper;
+import com.example.contacts.presentation.main.model.MenuClick;
+import com.example.contacts.presentation.sort.model.SortType;
+import com.example.contacts.utils.java.ThreadUtils;
+import com.example.contacts.utils.model.MergedContactUtils;
+
+public class MainViewModel extends AndroidViewModel {
+
+ private final ContactSourceRepository contactSourceRepository;
+ private final ContactRepository contactRepository;
+ private final ContactMerger contactMerger;
+ private final ContactUiMapper uiMapper;
+
+ private final MutableLiveData> contactsLiveDate = new MutableLiveData<>();
+ private final MutableLiveData uiStateLiveDate = new MutableLiveData<>();
+
+ private final MainState state = new MainState();
+ private final UiState uiState = new UiState();
+
+ public MainViewModel(@NonNull Application application) {
+ super(application);
+ contactSourceRepository = new ContactSourceRepository(application);
+ contactRepository = new ContactRepository(application);
+ contactMerger = new ContactMerger();
+ uiMapper = new ContactUiMapper();
+ ThreadUtils.runAsync(this::initLoading);
+ }
+
+ public LiveData> getContactsLiveDate() {
+ return contactsLiveDate;
+ }
+
+ public MutableLiveData getUiStateLiveDate() {
+ return uiStateLiveDate;
+ }
+
+ public void initLoading() {
+ final Set sources = contactSourceRepository.getAllContactSources();
+ final List sourceNames = sources.stream()
+ .map(ContactSource::getName)
+ .collect(Collectors.toList());
+ final List contacts = contactRepository.getContacts(sourceNames);
+
+ final List allContacts = contactMerger.getMergedContacts(contacts, sources);
+ state.setAllContacts(allContacts);
+ mapContactsAndUpdate();
+ }
+
+ public void search() {
+ mapContactsAndUpdate();
+ }
+
+ public void onMenuClick(MenuClick click) {
+ switch (click) {
+ case SORT:
+ uiState.actions.showSortTypeDialog.data = state.getSortType();
+ break;
+ case FILTER:
+ uiState.actions.showFilterContactTypeDialog.data = new HashSet<>(state.getContactTypes());
+ break;
+ case SEARCH:
+ uiState.searchVisibility = !uiState.searchVisibility;
+ break;
+ }
+ updateUiState();
+ }
+
+ public void updateSortType(SortType sortType) {
+ state.setSortType(sortType);
+ updateBadges();
+ mapContactsAndUpdate();
+ }
+
+ public void updateFilterContactTypes(Set filterContactTypes) {
+ state.setContactTypes(filterContactTypes);
+ updateBadges();
+ mapContactsAndUpdate();
+ }
+
+ public void onBackPressed() {
+ if (uiState.searchVisibility) {
+ uiState.searchVisibility = false;
+ } else {
+ uiState.actions.finishActivity.data = true;
+ }
+ updateUiState();
+ }
+
+ public void updateSearchText(String query) {
+ state.setQuery(query);
+ uiState.resetSearchButtonVisibility = state.getQuery().length() != 0;
+ updateUiState();
+ }
+
+ private void updateBadges() {
+ if (state.getSortType() != state.getDefaultSortType()) {
+ uiState.menuBadges.sort = new UiState.MenuBadge(0);
+ } else {
+ uiState.menuBadges.sort = null;
+ }
+
+ if (!state.getContactTypes().equals(state.getDefaultContactTypes())) {
+ uiState.menuBadges.filter = new UiState.MenuBadge(state.getContactTypes().size());
+ } else {
+ uiState.menuBadges.filter = null;
+ }
+
+ updateUiState();
+ }
+
+ private void mapContactsAndUpdate() {
+ final List uiContacts = state.getAllContacts().stream()
+ .filter(contact -> MergedContactUtils.contains(contact, state.getQuery()))
+ .filter(contact -> MergedContactUtils.contains(contact, state.getContactTypes()))
+ .sorted(createComparator(state.getSortType()))
+ .map(uiMapper::map)
+ .collect(Collectors.toList());
+ contactsLiveDate.postValue(uiContacts);
+ }
+
+ private Comparator createComparator(SortType type) {
+ switch (type) {
+ case BY_NAME:
+ return createComparator(MergedContact::getFirstName)
+ .thenComparing(createComparator(MergedContact::getSurname))
+ .thenComparing(createComparator(MergedContact::getNormalizedNumber))
+ .thenComparing(createComparator(MergedContact::getEmail));
+ case BY_NAME_REVERSED:
+ return createReversedComparator(MergedContact::getFirstName)
+ .thenComparing(createReversedComparator(MergedContact::getSurname))
+ .thenComparing(createReversedComparator(MergedContact::getNormalizedNumber))
+ .thenComparing(createReversedComparator(MergedContact::getEmail));
+ case BY_SURNAME:
+ return createComparator(MergedContact::getSurname)
+ .thenComparing(createComparator(MergedContact::getFirstName))
+ .thenComparing(createComparator(MergedContact::getNormalizedNumber))
+ .thenComparing(createComparator(MergedContact::getEmail));
+ case BY_SURNAME_REVERSED:
+ return createReversedComparator(MergedContact::getSurname)
+ .thenComparing(createReversedComparator(MergedContact::getFirstName))
+ .thenComparing(createReversedComparator(MergedContact::getNormalizedNumber))
+ .thenComparing(createReversedComparator(MergedContact::getEmail));
+ default:
+ throw new IllegalArgumentException("Not supported SortType");
+ }
+ }
+
+ private Comparator createComparator(Function keyExtractor) {
+ return (left, right) -> {
+ final String leftField = keyExtractor.apply(left);
+ final String rightField = keyExtractor.apply(right);
+ if (!TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) {
+ return leftField.compareTo(rightField);
+ }
+ // Empty lines should be after
+ if (TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) {
+ return 1;
+ }
+ if (!TextUtils.isEmpty(leftField) && TextUtils.isEmpty(rightField)) {
+ return -1;
+ }
+ return 0;
+ };
+ }
+
+ private Comparator createReversedComparator(Function keyExtractor) {
+ return (left, right) -> {
+ final String leftField = keyExtractor.apply(left);
+ final String rightField = keyExtractor.apply(right);
+ if (!TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) {
+ return rightField.compareTo(leftField);
+ }
+ // Empty lines should be after
+ if (TextUtils.isEmpty(leftField) && !TextUtils.isEmpty(rightField)) {
+ return 1;
+ }
+ if (!TextUtils.isEmpty(leftField) && TextUtils.isEmpty(rightField)) {
+ return -1;
+ }
+ return 0;
+ };
+ }
+
+ private void updateUiState() {
+ uiStateLiveDate.setValue(uiState.copy());
+ uiState.actions.clear();
+ }
+
+ public static class UiState {
+
+ public boolean searchVisibility = false;
+ public boolean resetSearchButtonVisibility = false;
+
+ public Actions actions = new Actions();
+ public MenuBadges menuBadges = new MenuBadges();
+
+ @NonNull
+ public UiState copy() {
+ final UiState copy = new UiState();
+ copy.searchVisibility = searchVisibility;
+ copy.resetSearchButtonVisibility = resetSearchButtonVisibility;
+ copy.actions = actions.copy();
+ copy.menuBadges = menuBadges.copy();
+ return copy;
+ }
+
+ public static class Actions {
+ public Action finishActivity = new Action<>(false);
+ public Action showSortTypeDialog = new Action<>(null);
+ public Action> showFilterContactTypeDialog = new Action<>(Collections.emptySet());
+
+ @NonNull
+ public Actions copy() {
+ final Actions copy = new Actions();
+ copy.finishActivity = new Action<>(finishActivity.data);
+ copy.showSortTypeDialog = new Action<>(showSortTypeDialog.data);
+ copy.showFilterContactTypeDialog = new Action<>(showFilterContactTypeDialog.data);
+ return copy;
+ }
+
+ public void clear() {
+ finishActivity.data = false;
+ showSortTypeDialog.data = null;
+ showFilterContactTypeDialog.data = Collections.emptySet();
+ }
+ }
+
+ public static class Action {
+ @Nullable
+ public T data;
+
+ public Action(@Nullable T value) {
+ this.data = value;
+ }
+ }
+
+ public static class MenuBadges {
+
+ @Nullable
+ public MenuBadge sort = null;
+ @Nullable
+ public MenuBadge filter = null;
+ @Nullable
+ public MenuBadge search = null;
+
+ public MenuBadges copy() {
+ final MenuBadges copy = new MenuBadges();
+ copy.sort = sort == null ? null : new MenuBadge(sort.value);
+ copy.filter = filter == null ? null : new MenuBadge(filter.value);
+ copy.search = search == null ? null : new MenuBadge(search.value);
+ return copy;
+ }
+ }
+
+ public static class MenuBadge {
+
+ public int value;
+
+ public MenuBadge(int value) {
+ this.value = value;
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java b/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java
new file mode 100644
index 000000000..a339b2c38
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/main/model/MenuClick.java
@@ -0,0 +1,5 @@
+package com.example.contacts.presentation.main.model;
+
+public enum MenuClick {
+ SORT, FILTER, SEARCH
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java b/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java
new file mode 100644
index 000000000..75e28150b
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/sort/SortDialogFragment.java
@@ -0,0 +1,80 @@
+package com.example.contacts.presentation.sort;
+
+import android.os.Bundle;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.recyclerview.widget.DividerItemDecoration;
+
+import java.util.List;
+import java.util.Objects;
+
+import com.example.contacts.R;
+import com.example.contacts.presentation.base.BaseBottomSheetDialogFragment;
+import com.example.contacts.presentation.sort.model.SortType;
+
+public class SortDialogFragment extends BaseBottomSheetDialogFragment {
+
+ public static final String REQUEST_KEY = "REQUEST_KEY_SORT";
+ public static final String ARG_SELECTED_SORT_TYPE = "ARG_SELECTED_SORT_TYPE";
+
+ private SortTypeAdapter adapter;
+
+ private SortDialogFragment() {
+ super(SortViewModel.class);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ iniViewModel();
+ adapter = new SortTypeAdapter(viewModel::onSortTypeItemClick);
+ binding.recycler.setAdapter(adapter);
+
+ final DividerItemDecoration decoration = new DividerItemDecoration(requireActivity(), DividerItemDecoration.VERTICAL);
+ decoration.setDrawable(Objects.requireNonNull(ContextCompat.getDrawable(requireActivity(), R.drawable.item_decoration_16dp)));
+ binding.recycler.addItemDecoration(decoration);
+
+ viewModel.getSortTypesLiveDate().observe(this, this::updateSortTypes);
+ viewModel.getUiStateLiveDate().observe(this, this::updateState);
+ }
+
+ private void iniViewModel() {
+ final SortType defaultSortType = from(getArguments());
+ viewModel.init(defaultSortType);
+ }
+
+ private void updateSortTypes(List sortTypes) {
+ adapter.setItems(sortTypes);
+ }
+
+ private void updateState(SortViewModel.UiState state) {
+ binding.applyButton.setEnabled(state.isApplyEnable);
+
+ if (state.newSelectedSortType != null) {
+ getParentFragmentManager().setFragmentResult(REQUEST_KEY, createBundle(state.newSelectedSortType));
+ dismiss();
+ }
+ }
+
+ public static SortDialogFragment newInstance(SortType selectedSortType) {
+ final SortDialogFragment fragment = new SortDialogFragment();
+ fragment.setArguments(createBundle(selectedSortType));
+ return fragment;
+ }
+
+ public static SortType from(@Nullable Bundle bundle) {
+ if (bundle == null) {
+ return SortType.BY_NAME;
+ }
+ return (SortType) bundle.getSerializable(ARG_SELECTED_SORT_TYPE);
+ }
+
+ private static Bundle createBundle(SortType sortType) {
+ final Bundle bundle = new Bundle();
+ bundle.putSerializable(ARG_SELECTED_SORT_TYPE, sortType);
+ return bundle;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java
new file mode 100644
index 000000000..124837dd3
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeAdapter.java
@@ -0,0 +1,92 @@
+package com.example.contacts.presentation.sort;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.recyclerview.widget.AdapterListUpdateCallback;
+import androidx.recyclerview.widget.AsyncDifferConfig;
+import androidx.recyclerview.widget.AsyncListDiffer;
+import androidx.recyclerview.widget.DiffUtil;
+import androidx.recyclerview.widget.RecyclerView;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import com.example.contacts.R;
+import com.example.contacts.databinding.ItemSortBinding;
+import com.example.contacts.presentation.base.BaseListDiffCallback;
+import com.example.contacts.presentation.sort.model.SortType;
+
+public class SortTypeAdapter extends RecyclerView.Adapter {
+
+ private final AsyncListDiffer differ = new AsyncListDiffer<>(
+ new AdapterListUpdateCallback(this),
+ new AsyncDifferConfig.Builder<>(new BaseListDiffCallback()).build()
+ );
+
+ private final Consumer clickListener;
+
+ public SortTypeAdapter(Consumer clickListener) {
+ this.clickListener = clickListener;
+ }
+
+ @NonNull
+ @Override
+ public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ final LayoutInflater inflater = LayoutInflater.from(parent.getContext());
+ final ItemSortBinding binding = ItemSortBinding.inflate(inflater, parent, false);
+ return new ViewHolder(binding, clickListener);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
+ holder.bind(differ.getCurrentList().get(position));
+ }
+
+ @Override
+ public int getItemCount() {
+ return differ.getCurrentList().size();
+ }
+
+ public void setItems(List items) {
+ differ.submitList(items);
+ }
+
+ static class ViewHolder extends RecyclerView.ViewHolder {
+
+ private final ItemSortBinding binding;
+
+ private SortTypeUI data;
+
+ public ViewHolder(@NonNull ItemSortBinding binding, Consumer clickListener) {
+ super(binding.getRoot());
+ this.binding = binding;
+ this.binding.getRoot().setOnClickListener(v -> clickListener.accept(data));
+ }
+
+ public void bind(SortTypeUI data) {
+ this.data = data;
+ final int sortResId = resource(data.getSortType());
+ binding.text.setText(sortResId);
+ binding.selected.setVisibility(data.isSelected() ? View.VISIBLE : View.GONE);
+ }
+
+ private int resource(SortType sortType) {
+ switch (sortType) {
+ case BY_NAME:
+ return R.string.sort_by_name;
+ case BY_NAME_REVERSED:
+ return R.string.sort_by_name_reversed;
+ case BY_SURNAME:
+ return R.string.sort_by_surname;
+ case BY_SURNAME_REVERSED:
+ return R.string.sort_by_surname_reversed;
+ default:
+ throw new IllegalArgumentException("Not supported SortType");
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java
new file mode 100644
index 000000000..09dbac797
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/sort/SortTypeUI.java
@@ -0,0 +1,48 @@
+package com.example.contacts.presentation.sort;
+
+import androidx.annotation.NonNull;
+
+import com.example.contacts.presentation.base.ListDiffInterface;
+import com.example.contacts.presentation.sort.model.SortType;
+
+public class SortTypeUI implements ListDiffInterface {
+
+ private final SortType sortType;
+ private final boolean selected;
+
+ public SortTypeUI(@NonNull SortType sortType, boolean selected) {
+ this.sortType = sortType;
+ this.selected = selected;
+ }
+
+ public SortType getSortType() {
+ return sortType;
+ }
+
+ public boolean isSelected() {
+ return selected;
+ }
+
+ @Override
+ public boolean theSameAs(SortTypeUI other) {
+ return this.sortType == other.sortType;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ SortTypeUI that = (SortTypeUI) o;
+
+ if (selected != that.selected) return false;
+ return sortType == that.sortType;
+ }
+
+ @Override
+ public int hashCode() {
+ int result = sortType.hashCode();
+ result = 31 * result + (selected ? 1 : 0);
+ return result;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java b/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java
new file mode 100644
index 000000000..6da38a8fc
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/sort/SortViewModel.java
@@ -0,0 +1,74 @@
+package com.example.contacts.presentation.sort;
+
+import androidx.annotation.Nullable;
+import androidx.lifecycle.MutableLiveData;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.stream.Collectors;
+
+import com.example.contacts.presentation.base.BaseBottomSheetViewModel;
+import com.example.contacts.presentation.sort.model.SortType;
+
+public class SortViewModel extends BaseBottomSheetViewModel {
+
+ private final UiState uiState = new UiState();
+ private final MutableLiveData> sortTypesLiveDate = new MutableLiveData<>();
+ private final MutableLiveData uiStateLiveDate = new MutableLiveData<>();
+
+ private SortType defaultSortType;
+ private SortType selectedSortType;
+
+ public void init(SortType defaultSortType) {
+ this.defaultSortType = defaultSortType;
+ this.selectedSortType = defaultSortType;
+ updateSortTypes();
+ updateUiState();
+ }
+
+ public void onSortTypeItemClick(SortTypeUI sortType) {
+ selectedSortType = sortType.getSortType();
+ updateSortTypes();
+ updateUiState();
+ }
+
+ @Override
+ public void onApplyClick() {
+ uiState.newSelectedSortType = selectedSortType;
+ updateUiState();
+ }
+
+ @Override
+ public void onResetClick() {
+ selectedSortType = defaultSortType;
+ updateSortTypes();
+ updateUiState();
+ }
+
+ public MutableLiveData> getSortTypesLiveDate() {
+ return sortTypesLiveDate;
+ }
+
+ public MutableLiveData getUiStateLiveDate() {
+ return uiStateLiveDate;
+ }
+
+ private void updateSortTypes() {
+ final SortType[] sortTypes = SortType.values();
+ final List sortTypesUi = Arrays.stream(sortTypes)
+ .map(sortType -> new SortTypeUI(sortType, Objects.equals(sortType, selectedSortType)))
+ .collect(Collectors.toList());
+ sortTypesLiveDate.setValue(sortTypesUi);
+ }
+
+ private void updateUiState() {
+ uiState.isApplyEnable = defaultSortType != selectedSortType;
+ uiStateLiveDate.setValue(uiState);
+ }
+
+ static class UiState {
+ public boolean isApplyEnable = false;
+ @Nullable public SortType newSelectedSortType = null;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java b/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java
new file mode 100644
index 000000000..ce251e178
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/presentation/sort/model/SortType.java
@@ -0,0 +1,8 @@
+package com.example.contacts.presentation.sort.model;
+
+public enum SortType {
+ BY_NAME,
+ BY_NAME_REVERSED,
+ BY_SURNAME,
+ BY_SURNAME_REVERSED
+}
diff --git a/app/src/main/java/com/example/contacts/repository/ContactRepository.java b/app/src/main/java/com/example/contacts/repository/ContactRepository.java
new file mode 100644
index 000000000..252233a02
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/repository/ContactRepository.java
@@ -0,0 +1,233 @@
+package com.example.contacts.repository;
+
+import android.content.Context;
+import android.net.Uri;
+import android.provider.ContactsContract.CommonDataKinds;
+import android.provider.ContactsContract.CommonDataKinds.Organization;
+import android.provider.ContactsContract.CommonDataKinds.Phone;
+import android.provider.ContactsContract.CommonDataKinds.StructuredName;
+import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
+import android.provider.ContactsContract.Data;
+import android.provider.ContactsContract.RawContacts;
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+import android.util.SparseArray;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import com.example.contacts.model.Address;
+import com.example.contacts.model.Contact;
+import com.example.contacts.model.Email;
+import com.example.contacts.model.PhoneNumber;
+import com.example.contacts.utils.android.ContextUtils;
+import com.example.contacts.utils.android.CursorUtils;
+
+public class ContactRepository {
+
+ private final Context context;
+
+ public ContactRepository(Context context) {
+ this.context = context;
+ }
+
+ public List getContacts(Collection sources) {
+ if (!ContextUtils.hasContactPermissions(context)) {
+ return Collections.emptyList();
+ }
+
+ List contacts = new ArrayList<>();
+
+ final Uri uri = Data.CONTENT_URI;
+ final String[] projection = getContactProjection();
+ SparseArray> emails = getEmails(sources);
+ SparseArray> phoneNumbers = getPhoneNumbers(sources);
+ SparseArray> addresses = getAddresses(sources);
+
+ String[] mimeTypes = new String[]{Organization.CONTENT_ITEM_TYPE, StructuredName.CONTENT_ITEM_TYPE};
+ for (String mimeType : mimeTypes) {
+ String selection = Data.MIMETYPE + " = ?";
+ String[] selectionArgs = new String[]{mimeType};
+ String sortOrder = Data.RAW_CONTACT_ID;
+
+ ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> {
+ String accountName = CursorUtils.getString(cursor, RawContacts.ACCOUNT_NAME);
+
+ int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID);
+ String prefix = "";
+ String firstName = "";
+ String middleName = "";
+ String surname = "";
+ String suffix = "";
+
+ // ignore names at Organization type contacts
+ if (Objects.equals(mimeType, StructuredName.CONTENT_ITEM_TYPE)) {
+ prefix = CursorUtils.getString(cursor, StructuredName.PREFIX);
+ firstName = CursorUtils.getString(cursor, StructuredName.GIVEN_NAME);
+ middleName = CursorUtils.getString(cursor, StructuredName.MIDDLE_NAME);
+ surname = CursorUtils.getString(cursor, StructuredName.FAMILY_NAME);
+ suffix = CursorUtils.getString(cursor, StructuredName.SUFFIX);
+ }
+
+ String photoUri = CursorUtils.getString(cursor, StructuredName.PHOTO_URI);
+ int starred = CursorUtils.getInteger(cursor, StructuredName.STARRED);
+ int contactId = CursorUtils.getInteger(cursor, Data.CONTACT_ID);
+ String thumbnailUri = CursorUtils.getString(cursor, StructuredName.PHOTO_THUMBNAIL_URI);
+
+ Contact contact = new Contact(
+ id,
+ prefix,
+ firstName,
+ middleName,
+ surname,
+ suffix,
+ photoUri,
+ phoneNumbers.get(id, new ArrayList<>()),
+ emails.get(id, new ArrayList<>()),
+ addresses.get(id, new ArrayList<>()),
+ accountName,
+ starred,
+ contactId,
+ thumbnailUri
+ );
+
+ contacts.add(contact);
+ });
+ }
+
+ return Collections.unmodifiableList(contacts);
+ }
+
+ private String[] getContactProjection() {
+ return new String[]{
+ Data.MIMETYPE,
+ Data.CONTACT_ID,
+ Data.RAW_CONTACT_ID,
+ StructuredName.PREFIX,
+ StructuredName.GIVEN_NAME,
+ StructuredName.MIDDLE_NAME,
+ StructuredName.FAMILY_NAME,
+ StructuredName.SUFFIX,
+ StructuredName.PHOTO_URI,
+ StructuredName.PHOTO_THUMBNAIL_URI,
+ StructuredName.STARRED,
+ StructuredName.CUSTOM_RINGTONE,
+ RawContacts.ACCOUNT_NAME,
+ RawContacts.ACCOUNT_TYPE
+ };
+ }
+
+ private SparseArray> getEmails(Collection sources) {
+ SparseArray> emails = new SparseArray<>();
+ Uri uri = CommonDataKinds.Email.CONTENT_URI;
+ String[] projection = new String[]{
+ Data.RAW_CONTACT_ID,
+ CommonDataKinds.Email.DATA,
+ CommonDataKinds.Email.TYPE,
+ CommonDataKinds.Email.LABEL
+ };
+
+ String selection = getSourcesSelection(sources.size());
+ String[] selectionArgs = getSourcesSelectionArgs(sources);
+
+ ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> {
+ int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID);
+ String email = CursorUtils.getString(cursor, CommonDataKinds.Email.DATA);
+ int type = CursorUtils.getInteger(cursor, CommonDataKinds.Email.TYPE);
+ String label = CursorUtils.getString(cursor, CommonDataKinds.Email.LABEL);
+
+ if (!TextUtils.isEmpty(email)) {
+ if (emails.get(id) == null) {
+ emails.put(id, new ArrayList<>());
+ }
+ emails.get(id).add(new Email(email, type, label));
+ }
+ });
+ return emails;
+ }
+
+ private SparseArray> getPhoneNumbers(Collection sources) {
+ SparseArray> phoneNumbers = new SparseArray<>();
+ Uri uri = Phone.CONTENT_URI;
+ String[] projection = new String[]{
+ Data.RAW_CONTACT_ID,
+ Phone.NUMBER,
+ Phone.NORMALIZED_NUMBER,
+ Phone.TYPE,
+ Phone.LABEL
+ };
+
+ String selection = getSourcesSelection(sources.size());
+ String[] selectionArgs = getSourcesSelectionArgs(sources);
+
+ ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> {
+ int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID);
+ String number = CursorUtils.getString(cursor, Phone.NUMBER);
+ String normalizedNumber = CursorUtils.getString(cursor, Phone.NORMALIZED_NUMBER);
+ int type = CursorUtils.getInteger(cursor, Phone.TYPE);
+ String label = CursorUtils.getString(cursor, Phone.LABEL);
+
+ if (!TextUtils.isEmpty(number)) {
+ if (TextUtils.isEmpty(normalizedNumber)) {
+ normalizedNumber = PhoneNumberUtils.normalizeNumber(number);
+ }
+ if (phoneNumbers.get(id) == null) {
+ phoneNumbers.put(id, new ArrayList<>());
+ }
+ PhoneNumber phoneNumber = new PhoneNumber(number, type, label, normalizedNumber);
+ phoneNumbers.get(id).add(phoneNumber);
+ }
+ });
+
+ return phoneNumbers;
+ }
+
+ private SparseArray> getAddresses(Collection sources) {
+ SparseArray> addresses = new SparseArray<>();
+ Uri uri = StructuredPostal.CONTENT_URI;
+ String[] projection = new String[]{
+ Data.RAW_CONTACT_ID,
+ StructuredPostal.FORMATTED_ADDRESS,
+ StructuredPostal.TYPE,
+ StructuredPostal.LABEL
+ };
+
+ String selection = getSourcesSelection(sources.size());
+ String[] selectionArgs = getSourcesSelectionArgs(sources);
+
+ ContextUtils.query(context, uri, projection, selection, selectionArgs, cursor -> {
+ int id = CursorUtils.getInteger(cursor, Data.RAW_CONTACT_ID);
+ String address = CursorUtils.getString(cursor, StructuredPostal.FORMATTED_ADDRESS);
+ int type = CursorUtils.getInteger(cursor, StructuredPostal.TYPE);
+ String label = CursorUtils.getString(cursor, StructuredPostal.LABEL);
+
+ if (!TextUtils.isEmpty(address)) {
+ if (addresses.get(id) == null) {
+ addresses.put(id, new ArrayList<>());
+ }
+
+ addresses.get(id).add(new Address(address, type, label));
+ }
+ });
+
+ return addresses;
+ }
+
+ private String getSourcesSelection(int count) {
+ return RawContacts.ACCOUNT_NAME + " IN (" + getQuestionMarks(count) + ")";
+ }
+
+ private String getQuestionMarks(int count) {
+ final String[] symbols = new String[count];
+ Arrays.fill(symbols, "?");
+ return TextUtils.join(",", symbols);
+ }
+
+ private String[] getSourcesSelectionArgs(Collection sources) {
+ return sources.toArray(new String[0]);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java b/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java
new file mode 100644
index 000000000..a15360ad7
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/repository/ContactSourceRepository.java
@@ -0,0 +1,147 @@
+package com.example.contacts.repository;
+
+import android.accounts.Account;
+import android.accounts.AccountManager;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderResult;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.net.Uri;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.RawContacts;
+import android.provider.ContactsContract.Settings;
+
+import androidx.annotation.Nullable;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.example.contacts.model.ContactSource;
+import com.example.contacts.utils.Constants;
+import com.example.contacts.utils.android.ContextUtils;
+import com.example.contacts.utils.android.CursorUtils;
+
+public class ContactSourceRepository {
+
+ private final Context context;
+
+ private boolean wasLocalAccountInitialized = false;
+
+ public ContactSourceRepository(Context context) {
+ this.context = context;
+ }
+
+ public Set getAllContactSources() {
+ if (!ContextUtils.hasContactPermissions(context)) {
+ return Collections.emptySet();
+ }
+ if (!wasLocalAccountInitialized) {
+ initializeLocalPhoneAccount();
+ wasLocalAccountInitialized = true;
+ }
+
+ final Set contactSources = new HashSet<>();
+ final Account[] accounts = AccountManager.get(context).getAccounts();
+ final Set accountSources = Arrays.stream(accounts)
+ .map(this::getContactSource)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+
+ final Set contentResolverSources = new HashSet<>(getContentResolverAccounts());
+
+ contactSources.addAll(accountSources);
+ contactSources.addAll(contentResolverSources);
+
+ if (!containsRegularPhoneSource(contactSources)) {
+ contactSources.add(new ContactSource(Constants.StorageType.PHONE_STORAGE, Constants.StorageType.PHONE_STORAGE, Constants.StorageType.PHONE_STORAGE));
+ }
+
+ contactSources.add(new ContactSource(Constants.StorageType.SMT_PRIVATE, Constants.StorageType.SMT_PRIVATE, Constants.StorageType.PHONE_STORAGE_PRIVATE));
+
+ return Collections.unmodifiableSet(contactSources);
+ }
+
+ @Nullable
+ private ContactSource getContactSource(Account account) {
+ if (isSyncable(account)) {
+ return new ContactSource(account.name, account.type, getAccountPublicName(account.type, account.name));
+ } else {
+ return null;
+ }
+ }
+
+ private Set getContentResolverAccounts() {
+ final Uri[] uris = {Groups.CONTENT_URI, Settings.CONTENT_URI, RawContacts.CONTENT_URI};
+ final Set sources = new HashSet<>();
+ for (Uri uri : uris) {
+ sources.addAll(fillSourcesFromUri(uri));
+ }
+ return Collections.unmodifiableSet(sources);
+ }
+
+ private Set fillSourcesFromUri(Uri uri) {
+ final Set sources = new HashSet<>();
+ final String[] projection = {RawContacts.ACCOUNT_NAME, RawContacts.ACCOUNT_TYPE};
+ ContextUtils.query(context, uri, projection, cursor -> {
+ final String name = CursorUtils.getString(cursor, RawContacts.ACCOUNT_NAME);
+ final String type = CursorUtils.getString(cursor, RawContacts.ACCOUNT_TYPE);
+ final ContactSource source = new ContactSource(name, type, getAccountPublicName(type, name));
+ sources.add(source);
+ });
+ return Collections.unmodifiableSet(sources);
+ }
+
+ private boolean isSyncable(Account account) {
+ return ContentResolver.getIsSyncable(account, ContactsContract.AUTHORITY) == 1;
+ }
+
+ private String getAccountPublicName(String type, String name) {
+ switch (type) {
+ case (Constants.Packages.GOOGLE):
+ return Constants.StorageType.GOOGLE;
+ case (Constants.Packages.TELEGRAM):
+ return Constants.StorageType.TELEGRAM;
+ case (Constants.Packages.SIGNAL):
+ return Constants.StorageType.SIGNAL;
+ case (Constants.Packages.WHATSAPP):
+ return Constants.StorageType.WHATSAPP;
+ case (Constants.Packages.VIBER):
+ return Constants.StorageType.VIBER;
+ case (Constants.Packages.THREEMA):
+ return Constants.StorageType.THREEMA;
+ default:
+ return name;
+ }
+ }
+
+ private void initializeLocalPhoneAccount() {
+ try {
+ final ContentProviderOperation.Builder builder = ContentProviderOperation.newInsert(RawContacts.CONTENT_URI);
+ builder.withValue(RawContacts.ACCOUNT_NAME, null);
+ builder.withValue(RawContacts.ACCOUNT_TYPE, null);
+
+ final ArrayList operations = new ArrayList<>();
+ operations.add(builder.build());
+
+ final ContentProviderResult[] results = context.getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations);
+ final Optional rawContactUri = Arrays.stream(results).findFirst();
+ rawContactUri.ifPresent(value -> context.getContentResolver().delete(value.uri, null, null));
+ } catch (Exception ignored) {
+
+ }
+ }
+
+ private boolean containsRegularPhoneSource(Set contactSources) {
+ return contactSources.stream().anyMatch(account -> {
+ final String accountType = account.getType();
+ return accountType.startsWith("com.google") || accountType.startsWith("com.android") || accountType.startsWith("com.qualcomm");
+ });
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java b/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java
new file mode 100644
index 000000000..0273c02db
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/ui/widget/ContactTypeImageView.java
@@ -0,0 +1,35 @@
+package com.example.contacts.ui.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+
+import com.example.contacts.model.ContactType;
+import com.example.contacts.utils.model.ContactTypeUtils;
+
+public class ContactTypeImageView extends StackImageView {
+
+ public ContactTypeImageView(Context context) {
+ super(context);
+ }
+
+ public ContactTypeImageView(Context context, AttributeSet attrs) {
+ super(context, attrs);
+ }
+
+ public ContactTypeImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ }
+
+ public ContactTypeImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ }
+
+ @Override
+ public void loadItem(ContactType item, @NonNull ImageView icon) {
+ int iconRes = ContactTypeUtils.getIconRes(item);
+ icon.setImageResource(iconRes);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java b/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java
new file mode 100644
index 000000000..a52c5f41a
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/ui/widget/DividerItemDecoration.java
@@ -0,0 +1,133 @@
+package com.example.contacts.ui.widget;
+
+import android.content.Context;
+import android.graphics.Canvas;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.view.View;
+import android.widget.LinearLayout;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.example.contacts.utils.android.ContextUtils;
+
+
+public class DividerItemDecoration extends RecyclerView.ItemDecoration {
+
+ public static final int HORIZONTAL = LinearLayout.HORIZONTAL;
+ public static final int VERTICAL = LinearLayout.VERTICAL;
+
+ @NonNull
+ private final Drawable dividerFirst;
+ @NonNull
+ private final Drawable dividerOther;
+
+ private int mOrientation;
+
+ private final Rect mBounds = new Rect();
+
+ public DividerItemDecoration(@NonNull Context context, @DrawableRes int dividerFirst, @DrawableRes int dividerOther, int orientation) {
+ this.dividerFirst = ContextUtils.requireDrawable(context, dividerFirst);
+ this.dividerOther = ContextUtils.requireDrawable(context, dividerOther);
+ setOrientation(orientation);
+ }
+
+ public DividerItemDecoration(@NonNull Context context, @DrawableRes int divider, int orientation) {
+ this(context, divider, divider, orientation);
+ }
+
+ public void setOrientation(int orientation) {
+ if (orientation != HORIZONTAL && orientation != VERTICAL) {
+ throw new IllegalArgumentException("Invalid orientation. It should be either HORIZONTAL or VERTICAL");
+ }
+ mOrientation = orientation;
+ }
+
+ @Override
+ public void onDraw(@NonNull Canvas c, RecyclerView parent, @NonNull RecyclerView.State state) {
+ if (parent.getLayoutManager() == null) {
+ return;
+ }
+ if (mOrientation == VERTICAL) {
+ drawVertical(c, parent);
+ } else {
+ drawHorizontal(c, parent);
+ }
+ }
+
+ private void drawVertical(Canvas canvas, RecyclerView parent) {
+ canvas.save();
+ final int left;
+ final int right;
+ //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
+ if (parent.getClipToPadding()) {
+ left = parent.getPaddingLeft();
+ right = parent.getWidth() - parent.getPaddingRight();
+ canvas.clipRect(left, parent.getPaddingTop(), right,
+ parent.getHeight() - parent.getPaddingBottom());
+ } else {
+ left = 0;
+ right = parent.getWidth();
+ }
+
+ final int childCount = parent.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = parent.getChildAt(i);
+ parent.getDecoratedBoundsWithMargins(child, mBounds);
+ final int bottom = mBounds.bottom + Math.round(child.getTranslationY());
+ final int top = bottom - getDivider(i).getIntrinsicHeight();
+ getDivider(i).setBounds(left, top, right, bottom);
+ getDivider(i).draw(canvas);
+ }
+ canvas.restore();
+ }
+
+ private void drawHorizontal(Canvas canvas, RecyclerView parent) {
+ canvas.save();
+ final int top;
+ final int bottom;
+ //noinspection AndroidLintNewApi - NewApi lint fails to handle overrides.
+ if (parent.getClipToPadding()) {
+ top = parent.getPaddingTop();
+ bottom = parent.getHeight() - parent.getPaddingBottom();
+ canvas.clipRect(parent.getPaddingLeft(), top,
+ parent.getWidth() - parent.getPaddingRight(), bottom);
+ } else {
+ top = 0;
+ bottom = parent.getHeight();
+ }
+
+ final int childCount = parent.getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ final View child = parent.getChildAt(i);
+ parent.getDecoratedBoundsWithMargins(child, mBounds);
+ final int right = mBounds.right + Math.round(child.getTranslationX());
+ final int left = right - getDivider(i).getIntrinsicWidth();
+ getDivider(i).setBounds(left, top, right, bottom);
+ getDivider(i).draw(canvas);
+ }
+ canvas.restore();
+ }
+
+ @Override
+ public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent,
+ @NonNull RecyclerView.State state) {
+ final int position = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewAdapterPosition();
+ if (mOrientation == VERTICAL) {
+ outRect.set(0, 0, 0, getDivider(position).getIntrinsicHeight());
+ } else {
+ outRect.set(0, 0, getDivider(position).getIntrinsicWidth(), 0);
+ }
+ }
+
+ @NonNull
+ private Drawable getDivider(int index) {
+ if (index == 0) {
+ return dividerFirst;
+ } else {
+ return dividerOther;
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java b/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java
new file mode 100644
index 000000000..ae5274653
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/ui/widget/StackImageView.java
@@ -0,0 +1,113 @@
+package com.example.contacts.ui.widget;
+
+import android.content.Context;
+import android.content.res.TypedArray;
+import android.util.AttributeSet;
+import android.widget.FrameLayout;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+
+import java.util.List;
+
+import com.example.contacts.R;
+
+public abstract class StackImageView extends FrameLayout {
+
+ protected static final int DEFAULT_COUNT = 3;
+ protected static final int DEFAULT_ICON_SIZE_RES = R.dimen.stack_image_view_icon_size;
+ protected static final int DEFAULT_BORDER_SIZE_RES = R.dimen.stack_image_view_icon_border;
+ protected static final int DEFAULT_ICON_OFFSET_RES = R.dimen.stack_image_view_icon_offset;
+
+ protected int itemsCount;
+ protected int maxCount;
+ protected int iconSize;
+ protected int borderSize;
+ protected int iconOffset;
+
+ private ImageView[] icons;
+
+ public StackImageView(Context context) {
+ this(context, null, 0);
+ }
+
+ public StackImageView(Context context, AttributeSet attrs) {
+ this(context, attrs, 0);
+ }
+
+ public StackImageView(Context context, AttributeSet attrs, int defStyleAttr) {
+ super(context, attrs, defStyleAttr);
+ applyAttrs(attrs);
+ init();
+ }
+
+ public StackImageView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
+ super(context, attrs, defStyleAttr, defStyleRes);
+ applyAttrs(attrs);
+ init();
+ }
+
+ private void applyAttrs(AttributeSet attrs) {
+ TypedArray a = getContext().obtainStyledAttributes(attrs, R.styleable.StackImageView);
+
+ maxCount = a.getInteger(R.styleable.StackImageView_iconsCount, DEFAULT_COUNT);
+ iconSize = a.getDimensionPixelSize(R.styleable.StackImageView_iconItemSize,
+ getResources().getDimensionPixelSize(DEFAULT_ICON_SIZE_RES));
+ borderSize = a.getDimensionPixelSize(R.styleable.StackImageView_borderSize,
+ getResources().getDimensionPixelSize(DEFAULT_BORDER_SIZE_RES));
+ iconOffset = a.getDimensionPixelSize(R.styleable.StackImageView_iconOffset,
+ getResources().getDimensionPixelSize(DEFAULT_ICON_OFFSET_RES));
+
+ a.recycle();
+
+ if (maxCount <= 0) {
+ throw new IllegalStateException("icons_count must be greater than zero");
+ }
+ }
+
+ private void init() {
+ icons = new ImageView[maxCount];
+ for (int i = maxCount - 1; i >= 0; i--) {
+ ImageView view = new ImageView(getContext());
+
+ FrameLayout.LayoutParams layoutParams = new LayoutParams(iconSize, iconSize);
+ layoutParams.leftMargin = iconOffset * i;
+ view.setLayoutParams(layoutParams);
+
+ view.setVisibility(GONE);
+ view.setBackgroundResource(R.drawable.bg_image_view_borger);
+
+ addView(view);
+ icons[i] = view;
+ }
+ }
+
+ public void setData(List items) {
+ itemsCount = items.size();
+ int size = Math.min(maxCount, itemsCount);
+
+ for (int i = 0; i < size; i++) {
+ ImageView icon = icons[i];
+ icon.setVisibility(VISIBLE);
+
+ T item = items.get(i);
+ if (i < size - 1 || size == itemsCount) {
+ loadItem(item, icon);
+ } else {
+ loadLastItem(item, icon);
+ }
+ }
+
+ if (size < maxCount) {
+ for (int i = size; i < maxCount; i++) {
+ icons[i].setVisibility(GONE);
+ }
+ }
+ }
+
+ public abstract void loadItem(T item, @NonNull ImageView icon);
+
+ public void loadLastItem(T item, @NonNull ImageView icon) {
+ loadItem(item, icon);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/Constants.java b/app/src/main/java/com/example/contacts/utils/Constants.java
new file mode 100644
index 000000000..45433ec37
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/Constants.java
@@ -0,0 +1,28 @@
+package com.example.contacts.utils;
+
+public class Constants {
+
+ public static class Packages {
+ public static final String GOOGLE = "com.google";
+ public static final String TELEGRAM = "org.telegram.messenger";
+ public static final String SIGNAL = "org.thoughtcrime.securesms";
+ public static final String WHATSAPP = "com.whatsapp";
+ public static final String VIBER = "com.viber.voip";
+ public static final String THREEMA = "ch.threema.app";
+ }
+
+ public static class StorageType{
+ public static final String SMT_PRIVATE = "smt_private" ;
+ public static final String GOOGLE = "Google";
+ public static final String TELEGRAM = "Telegram";
+ public static final String SIGNAL = "Signal";
+ public static final String WHATSAPP = "WhatsApp";
+ public static final String VIBER = "Viber";
+ public static final String THREEMA = "Threema";
+ public static final String PHONE_STORAGE = "Phone storage";
+ public static final String PHONE_STORAGE_PRIVATE = "Phone storage (hidden)";
+ }
+
+
+
+}
diff --git a/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java b/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java
new file mode 100644
index 000000000..268145ab4
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/android/ContextUtils.java
@@ -0,0 +1,55 @@
+package com.example.contacts.utils.android;
+
+import android.Manifest;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.database.Cursor;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import java.util.Objects;
+import java.util.function.Consumer;
+
+public class ContextUtils {
+
+ public static boolean hasContactPermissions(Context context) {
+ return hasPermission(context, Manifest.permission.READ_CONTACTS);
+ }
+
+ private static boolean hasPermission(Context context, String permission) {
+ return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED;
+ }
+
+ public static void query(Context context, Uri uri, String[] projection, Consumer callback) {
+ try (Cursor cursor = context.getContentResolver().query(uri, projection, null, null, null)) {
+ if (cursor.moveToFirst()) {
+ do {
+ callback.accept(cursor);
+ } while (cursor.moveToNext());
+ }
+ } catch (Exception ignored) {
+
+ }
+ }
+
+ public static void query(Context context, Uri uri, String[] projection, String selection, String[] selectionArgs, Consumer callback) {
+ try (Cursor cursor = context.getContentResolver().query(uri, projection, selection, selectionArgs, null)) {
+ if (cursor.moveToFirst()) {
+ do {
+ callback.accept(cursor);
+ } while (cursor.moveToNext());
+ }
+ } catch (Exception ignored) {
+
+ }
+ }
+
+ @NonNull
+ public static Drawable requireDrawable(Context context, @DrawableRes int id) {
+ return Objects.requireNonNull(ContextCompat.getDrawable(context, id));
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java b/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java
new file mode 100644
index 000000000..6ea868ec3
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/android/CursorUtils.java
@@ -0,0 +1,25 @@
+package com.example.contacts.utils.android;
+
+import android.database.Cursor;
+
+public class CursorUtils {
+
+ public static String getString(Cursor cursor, String key) {
+ final int index = cursor.getColumnIndex(key);
+ if (index >= 0) {
+ final String string = cursor.getString(index);
+ return string != null ? string : "";
+ } else {
+ return "";
+ }
+ }
+
+ public static int getInteger(Cursor cursor, String key) {
+ final int index = cursor.getColumnIndex(key);
+ if (index >= 0) {
+ return cursor.getInt(index);
+ } else {
+ return -1;
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/android/Debouncer.java b/app/src/main/java/com/example/contacts/utils/android/Debouncer.java
new file mode 100644
index 000000000..c5de9c50a
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/android/Debouncer.java
@@ -0,0 +1,42 @@
+package com.example.contacts.utils.android;
+
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+
+import androidx.annotation.NonNull;
+
+public class Debouncer {
+
+ private static final int MESSAGE_ID = 1;
+ private static final int DELAY = 500;
+
+ private final OnValueUpdateListener listener;
+
+ public Debouncer(OnValueUpdateListener listener) {
+ this.listener = listener;
+ }
+
+ @SuppressWarnings("unchecked")
+ private final Handler handler = new Handler(Looper.getMainLooper()) {
+ @Override
+ public void handleMessage(@NonNull Message message) {
+ if (message.what == MESSAGE_ID) {
+ listener.onValueUpdate((T) message.obj);
+ return;
+ }
+ super.handleMessage(message);
+ }
+ };
+
+ public void updateValue(T value) {
+ final Message message = Message.obtain(handler, MESSAGE_ID, value);
+ handler.removeMessages(MESSAGE_ID);
+ handler.sendMessageDelayed(message, DELAY);
+ }
+
+ @FunctionalInterface
+ public interface OnValueUpdateListener {
+ void onValueUpdate(T value);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java b/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java
new file mode 100644
index 000000000..290d19987
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/java/ThreadUtils.java
@@ -0,0 +1,25 @@
+package com.example.contacts.utils.java;
+
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+
+public class ThreadUtils {
+
+ public static void runAsync(Supplier supplier, Consumer consumer) {
+ final Handler handler = new Handler(Looper.getMainLooper());
+ new Thread() {
+ @Override
+ public void run() {
+ final T value = supplier.get();
+ handler.post(() -> consumer.accept(value));
+ }
+ }.start();
+ }
+
+ public static void runAsync(Runnable runnable) {
+ new Thread(runnable).start();
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java b/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java
new file mode 100644
index 000000000..9ceae265f
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/model/ContactTypeUtils.java
@@ -0,0 +1,74 @@
+package com.example.contacts.utils.model;
+
+import androidx.annotation.DrawableRes;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.example.contacts.R;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.filter.model.FilterContactType;
+import com.example.contacts.utils.Constants;
+
+public class ContactTypeUtils {
+
+ @DrawableRes
+ public static int getIconRes(@NonNull ContactType type) {
+ switch (type) {
+ case TELEGRAM:
+ return R.drawable.ic_type_telegram;
+ case WHATS_APP:
+ return R.drawable.ic_type_whatsapp;
+ case VIBER:
+ return R.drawable.ic_type_viber;
+ case SIGNAL:
+ return R.drawable.ic_type_signal;
+ case THREEMA:
+ return R.drawable.ic_type_threema;
+ case PHONE:
+ return R.drawable.ic_type_phone;
+ case EMAIL:
+ return R.drawable.ic_type_email;
+ default:
+ throw new IllegalArgumentException("Not supported type of ContactType");
+ }
+ }
+
+ public static FilterContactType toFilterContactType(ContactType type) {
+ switch (type) {
+ case TELEGRAM:
+ return FilterContactType.TELEGRAM;
+ case WHATS_APP:
+ return FilterContactType.WHATS_APP;
+ case VIBER:
+ return FilterContactType.VIBER;
+ case SIGNAL:
+ return FilterContactType.SIGNAL;
+ case THREEMA:
+ return FilterContactType.THREEMA;
+ case PHONE:
+ return FilterContactType.PHONE;
+ case EMAIL:
+ return FilterContactType.EMAIL;
+ default:
+ throw new IllegalArgumentException("Not supported ContactType");
+ }
+ }
+
+ @Nullable
+ public static ContactType parse(String value) {
+ switch (value) {
+ case Constants.StorageType.TELEGRAM:
+ return ContactType.TELEGRAM;
+ case Constants.StorageType.WHATSAPP:
+ return ContactType.WHATS_APP;
+ case Constants.StorageType.VIBER:
+ return ContactType.VIBER;
+ case Constants.StorageType.SIGNAL:
+ return ContactType.SIGNAL;
+ case Constants.StorageType.THREEMA:
+ return ContactType.THREEMA;
+ default:
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java b/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java
new file mode 100644
index 000000000..30ac49744
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/model/ContactUtils.java
@@ -0,0 +1,42 @@
+package com.example.contacts.utils.model;
+
+import android.text.TextUtils;
+
+import com.example.contacts.model.Contact;
+
+public class ContactUtils {
+
+ public static String getDisplayName(Contact contact) {
+ final String fullName = getFullName(contact);
+ if (!TextUtils.isEmpty(fullName)) {
+ return fullName;
+ }
+ final String phone = PhoneUtils.format(getFirstPhone(contact));
+ if (!TextUtils.isEmpty(phone)) {
+ return phone;
+ }
+ final String email = getFirstEmail(contact);
+ if (!TextUtils.isEmpty(email)) {
+ return email;
+ }
+ return "";
+ }
+
+ public static String getFullName(Contact contact) {
+ final String firstMiddle = (contact.getFirstName() + " " + contact.getMiddleName()).trim();
+ final String suffixComma = TextUtils.isEmpty(contact.getSuffix()) ? "" : ", " + contact.getSuffix();
+ return (contact.getPrefix() + " " + contact.getSurname() + " " + firstMiddle + suffixComma).trim();
+ }
+
+ public static String getFirstPhone(Contact contact) {
+ return !contact.getPhoneNumbers().isEmpty() ? contact.getPhoneNumbers().get(0).getValue().trim() : "";
+ }
+
+ public static String getFirstNormalizedPhone(Contact contact) {
+ return !contact.getPhoneNumbers().isEmpty() ? contact.getPhoneNumbers().get(0).getNormalizedNumber().trim() : "";
+ }
+
+ public static String getFirstEmail(Contact contact) {
+ return !contact.getEmails().isEmpty() ? contact.getEmails().get(0).getValue().trim() : "";
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java b/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java
new file mode 100644
index 000000000..01b310e5f
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/model/FilterContactTypeUtils.java
@@ -0,0 +1,57 @@
+package com.example.contacts.utils.model;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import com.example.contacts.R;
+import com.example.contacts.model.ContactType;
+import com.example.contacts.presentation.filter.model.FilterContactType;
+
+public class FilterContactTypeUtils {
+
+ @StringRes
+ public static int getStringRes(FilterContactType contactType) {
+ switch (contactType) {
+ case ALL:
+ return R.string.filter_contact_type_all;
+ case TELEGRAM:
+ return R.string.filter_contact_type_telegram;
+ case WHATS_APP:
+ return R.string.filter_contact_type_whatsapp;
+ case VIBER:
+ return R.string.filter_contact_type_viber;
+ case SIGNAL:
+ return R.string.filter_contact_type_signal;
+ case THREEMA:
+ return R.string.filter_contact_type_threema;
+ case PHONE:
+ return R.string.filter_contact_type_phone;
+ case EMAIL:
+ return R.string.filter_contact_type_email;
+ default:
+ throw new IllegalArgumentException("Not supported SortType");
+ }
+ }
+
+ @NonNull
+ public static ContactType toContactType(FilterContactType type) {
+ switch (type) {
+ case TELEGRAM:
+ return ContactType.TELEGRAM;
+ case WHATS_APP:
+ return ContactType.WHATS_APP;
+ case VIBER:
+ return ContactType.VIBER;
+ case SIGNAL:
+ return ContactType.SIGNAL;
+ case THREEMA:
+ return ContactType.THREEMA;
+ case PHONE:
+ return ContactType.PHONE;
+ case EMAIL:
+ return ContactType.EMAIL;
+ default:
+ throw new IllegalArgumentException("Not supported FilterContactType");
+ }
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java b/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java
new file mode 100644
index 000000000..9ef5d4fa6
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/model/MergedContactUtils.java
@@ -0,0 +1,52 @@
+package com.example.contacts.utils.model;
+
+import android.text.TextUtils;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import com.example.contacts.model.ContactType;
+import com.example.contacts.model.MergedContact;
+
+public class MergedContactUtils {
+
+ public static boolean contains(MergedContact contact, String query) {
+ final String lowerCaseQuery = query.toLowerCase();
+ if (TextUtils.isEmpty(lowerCaseQuery)) {
+ return true;
+ }
+ return contact.getFirstName().toLowerCase().contains(lowerCaseQuery) ||
+ contact.getMiddleName().toLowerCase().contains(lowerCaseQuery) ||
+ contact.getSurname().toLowerCase().contains(lowerCaseQuery) ||
+ contact.getNormalizedNumber().toLowerCase().contains(lowerCaseQuery) ||
+ contact.getPhone().toLowerCase().contains(lowerCaseQuery) ||
+ contact.getEmail().toLowerCase().contains(lowerCaseQuery);
+ }
+
+ public static boolean contains(MergedContact contact, Set types) {
+ if (types.isEmpty() || types.size() == ContactType.values().length) {
+ return true;
+ }
+ final List contactTypes = getContactTypes(contact);
+ return !Collections.disjoint(contactTypes, types);
+ }
+
+ public static List getContactTypes(MergedContact contact) {
+ final List allTypes = contact.getContactTypes().stream()
+ .map(ContactTypeUtils::parse)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toList());
+ if (!TextUtils.isEmpty(contact.getPhone())) {
+ allTypes.add(ContactType.PHONE);
+ }
+ if (!TextUtils.isEmpty(contact.getEmail())) {
+ allTypes.add(ContactType.EMAIL);
+ }
+ Collections.sort(allTypes);
+
+ return Collections.unmodifiableList(allTypes);
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java b/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java
new file mode 100644
index 000000000..86bf0d331
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/model/PhoneUtils.java
@@ -0,0 +1,14 @@
+package com.example.contacts.utils.model;
+
+import android.telephony.PhoneNumberUtils;
+import android.text.TextUtils;
+
+import java.util.Locale;
+
+public class PhoneUtils {
+
+ public static String format(String phone) {
+ final String formattedPhone = PhoneNumberUtils.formatNumber(phone, Locale.getDefault().getISO3Country());
+ return TextUtils.isEmpty(formattedPhone) ? phone : formattedPhone;
+ }
+}
diff --git a/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java b/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java
new file mode 100644
index 000000000..08c3699cb
--- /dev/null
+++ b/app/src/main/java/com/example/contacts/utils/widget/EditTextUtils.java
@@ -0,0 +1,49 @@
+package com.example.contacts.utils.widget;
+
+import android.text.Editable;
+import android.text.TextWatcher;
+import android.widget.EditText;
+
+import java.util.function.Consumer;
+
+import com.example.contacts.utils.android.Debouncer;
+
+public class EditTextUtils {
+
+ public static void debounce(EditText editText, Debouncer.OnValueUpdateListener listener) {
+ final Debouncer debouncer = new Debouncer<>(listener);
+ editText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ debouncer.updateValue(s);
+ }
+ });
+ }
+
+ public static void addTextListener(EditText editText, Consumer consumer) {
+ editText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+
+ }
+
+ @Override
+ public void onTextChanged(CharSequence s, int start, int before, int count) {
+
+ }
+
+ @Override
+ public void afterTextChanged(Editable s) {
+ consumer.accept(s);
+ }
+ });
+ }
+}
diff --git a/app/src/main/res/color/text_button_blue.xml b/app/src/main/res/color/text_button_blue.xml
new file mode 100644
index 000000000..53b750062
--- /dev/null
+++ b/app/src/main/res/color/text_button_blue.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/color/text_button_white.xml b/app/src/main/res/color/text_button_white.xml
new file mode 100644
index 000000000..427ba803d
--- /dev/null
+++ b/app/src/main/res/color/text_button_white.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/bg_button_blue.xml b/app/src/main/res/drawable/bg_button_blue.xml
new file mode 100644
index 000000000..7655fae8f
--- /dev/null
+++ b/app/src/main/res/drawable/bg_button_blue.xml
@@ -0,0 +1,29 @@
+
+
+ -
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_button_white.xml b/app/src/main/res/drawable/bg_button_white.xml
new file mode 100644
index 000000000..145cd99c4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_button_white.xml
@@ -0,0 +1,35 @@
+
+
+ -
+
+
-
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_image_view_borger.xml b/app/src/main/res/drawable/bg_image_view_borger.xml
new file mode 100644
index 000000000..8487a64d4
--- /dev/null
+++ b/app/src/main/res/drawable/bg_image_view_borger.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_search_box.xml b/app/src/main/res/drawable/bg_search_box.xml
new file mode 100644
index 000000000..5f66560ff
--- /dev/null
+++ b/app/src/main/res/drawable/bg_search_box.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/bg_splash.xml b/app/src/main/res/drawable/bg_splash.xml
new file mode 100644
index 000000000..e565e5259
--- /dev/null
+++ b/app/src/main/res/drawable/bg_splash.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_avatar.xml b/app/src/main/res/drawable/ic_avatar.xml
new file mode 100644
index 000000000..92961b4fa
--- /dev/null
+++ b/app/src/main/res/drawable/ic_avatar.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml
new file mode 100644
index 000000000..c231419c4
--- /dev/null
+++ b/app/src/main/res/drawable/ic_close.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_done.xml b/app/src/main/res/drawable/ic_done.xml
new file mode 100644
index 000000000..a7db8de6a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_done.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 000000000..14482c7ab
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 000000000..a99df9103
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_filter.xml b/app/src/main/res/drawable/ic_menu_filter.xml
new file mode 100644
index 000000000..235aef9df
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_filter.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_search.xml b/app/src/main/res/drawable/ic_menu_search.xml
new file mode 100644
index 000000000..c75a5e7a3
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_search.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_menu_sort.xml b/app/src/main/res/drawable/ic_menu_sort.xml
new file mode 100644
index 000000000..bf0699c88
--- /dev/null
+++ b/app/src/main/res/drawable/ic_menu_sort.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_email.xml b/app/src/main/res/drawable/ic_type_email.xml
new file mode 100644
index 000000000..f8dd390ba
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_email.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_phone.xml b/app/src/main/res/drawable/ic_type_phone.xml
new file mode 100644
index 000000000..434b0dbe8
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_phone.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_signal.xml b/app/src/main/res/drawable/ic_type_signal.xml
new file mode 100644
index 000000000..4cd687267
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_signal.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_telegram.xml b/app/src/main/res/drawable/ic_type_telegram.xml
new file mode 100644
index 000000000..0b977f18a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_telegram.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_threema.xml b/app/src/main/res/drawable/ic_type_threema.xml
new file mode 100644
index 000000000..6ade9526e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_threema.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_viber.xml b/app/src/main/res/drawable/ic_type_viber.xml
new file mode 100644
index 000000000..27383dd8a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_viber.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_type_whatsapp.xml b/app/src/main/res/drawable/ic_type_whatsapp.xml
new file mode 100644
index 000000000..bc2463481
--- /dev/null
+++ b/app/src/main/res/drawable/ic_type_whatsapp.xml
@@ -0,0 +1,12 @@
+
+
+
+
diff --git a/app/src/main/res/drawable/item_decoration_16dp.xml b/app/src/main/res/drawable/item_decoration_16dp.xml
new file mode 100644
index 000000000..ccb568058
--- /dev/null
+++ b/app/src/main/res/drawable/item_decoration_16dp.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/item_decoration_72dp.xml b/app/src/main/res/drawable/item_decoration_72dp.xml
new file mode 100644
index 000000000..c3aed1096
--- /dev/null
+++ b/app/src/main/res/drawable/item_decoration_72dp.xml
@@ -0,0 +1,9 @@
+
+
+ -
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/font/ys_display_medium.ttf b/app/src/main/res/font/ys_display_medium.ttf
new file mode 100644
index 000000000..860c389df
Binary files /dev/null and b/app/src/main/res/font/ys_display_medium.ttf differ
diff --git a/app/src/main/res/font/ys_display_regular.ttf b/app/src/main/res/font/ys_display_regular.ttf
new file mode 100644
index 000000000..4ac0cc71e
Binary files /dev/null and b/app/src/main/res/font/ys_display_regular.ttf differ
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 000000000..0a363b828
--- /dev/null
+++ b/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_bottom_sheet.xml b/app/src/main/res/layout/fragment_bottom_sheet.xml
new file mode 100644
index 000000000..1237c2b63
--- /dev/null
+++ b/app/src/main/res/layout/fragment_bottom_sheet.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml
new file mode 100644
index 000000000..e7393afbd
--- /dev/null
+++ b/app/src/main/res/layout/item_contact.xml
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_filter.xml b/app/src/main/res/layout/item_filter.xml
new file mode 100644
index 000000000..046a27fdb
--- /dev/null
+++ b/app/src/main/res/layout/item_filter.xml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_sort.xml b/app/src/main/res/layout/item_sort.xml
new file mode 100644
index 000000000..f6e5aeca2
--- /dev/null
+++ b/app/src/main/res/layout/item_sort.xml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/layout_search.xml b/app/src/main/res/layout/layout_search.xml
new file mode 100644
index 000000000..e6775b13f
--- /dev/null
+++ b/app/src/main/res/layout/layout_search.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/splash_activity.xml b/app/src/main/res/layout/splash_activity.xml
new file mode 100644
index 000000000..c78d8658d
--- /dev/null
+++ b/app/src/main/res/layout/splash_activity.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/menu_main.xml b/app/src/main/res/menu/menu_main.xml
new file mode 100644
index 000000000..e0326f986
--- /dev/null
+++ b/app/src/main/res/menu/menu_main.xml
@@ -0,0 +1,26 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 000000000..bbd3e0212
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 000000000..bbd3e0212
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..064e55752
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..6fd2a2977
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..4ad238545
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..d5804b8f3
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..f96e42f82
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e45824556
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..128727664
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..c6d9e1853
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..3ceda491b
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..e4589fa5c
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml
new file mode 100644
index 000000000..d186d6701
--- /dev/null
+++ b/app/src/main/res/values/attrs.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 000000000..4e484dc9d
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,17 @@
+
+
+ @color/color_blue
+
+ #FFFFFF
+ #1A1B22
+ #005FFF
+ #AEAFB4
+ #E6E8EB
+ #F0F1F3
+ #F54543
+ #33B471
+
+ #80FFFFFF
+ #80005FFF
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
new file mode 100644
index 000000000..d948c562b
--- /dev/null
+++ b/app/src/main/res/values/dimens.xml
@@ -0,0 +1,7 @@
+
+ 2dp
+ 26dp
+ 22dp
+
+ 7dp
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 000000000..e1aae673e
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,28 @@
+
+ Контакты
+
+ Сортировка
+ Фильтр
+ Поиск
+
+ Ничего не нашлось
+ Перейти в настройки
+
+ Сбросить
+ Применить
+
+ По имени (А-Я / A-Z)
+ По имени (Я-А / Z-A)
+ По фамилии (А-Я / A-Z)
+ По фамилии (Я-А / Z-A)
+
+ Выбрать все
+ Telegram
+ WhatsApp
+ Viber
+ Signal
+ Threema
+ Номер телефона
+ E-mail
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 000000000..c76ab3d94
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/ru/yandex/practicum/contacts/ExampleUnitTest.java b/app/src/test/java/ru/yandex/practicum/contacts/ExampleUnitTest.java
new file mode 100644
index 000000000..5572c1aa5
--- /dev/null
+++ b/app/src/test/java/ru/yandex/practicum/contacts/ExampleUnitTest.java
@@ -0,0 +1,17 @@
+package ru.yandex.practicum.contacts;
+
+import org.junit.Test;
+
+import static org.junit.Assert.*;
+
+/**
+ * Example local unit test, which will execute on the development machine (host).
+ *
+ * @see Testing documentation
+ */
+public class ExampleUnitTest {
+ @Test
+ public void addition_isCorrect() {
+ assertEquals(4, 2 + 2);
+ }
+}
\ No newline at end of file
diff --git a/build.gradle b/build.gradle
new file mode 100644
index 000000000..32ceac6ea
--- /dev/null
+++ b/build.gradle
@@ -0,0 +1,6 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {
+ id 'com.android.application' version '8.6.1' apply false
+ id 'com.android.library' version '8.6.1' apply false
+ id 'org.jetbrains.kotlin.android' version '2.0.20' apply false
+}
\ No newline at end of file
diff --git a/build_log.txt b/build_log.txt
new file mode 100644
index 000000000..6def70dbd
--- /dev/null
+++ b/build_log.txt
@@ -0,0 +1,20 @@
+===============================================
+Build started at 27.10.2025 19:28:55,20
+===============================================
+
+Checking Java...
+java version "1.8.0_471"
+Java(TM) SE Runtime Environment (build 1.8.0_471-b09)
+Java HotSpot(TM) 64-Bit Server VM (build 25.471-b09, mixed mode)
+
+Checking connected devices...
+List of devices attached
+emulator-5554 unauthorized
+
+
+Building project...
+
+ERROR: JAVA_HOME is set to an invalid directory: C:\Program Files\Eclipse Adoptium\jdk-17.0.16.8-hotspot
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation.
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 000000000..b56374fd9
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,24 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+android.overridePathCheck=true
+org.gradle.java.home=C:\\Android\\Android Studio\\jbr
+android.javaCompile.suppressSourceTargetDeprecationWarning=true
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..e708b1c02
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..7ea0ef3e9
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Thu Oct 17 01:49:43 MSK 2024
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..4f906e0c8
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+##
+## Gradle start up script for UN*X
+##
+##############################################################################
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+ ls=`ls -ld "$PRG"`
+ link=`expr "$ls" : '.*-> \(.*\)$'`
+ if expr "$link" : '/.*' > /dev/null; then
+ PRG="$link"
+ else
+ PRG=`dirname "$PRG"`"/$link"
+ fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn () {
+ echo "$*"
+}
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "`uname`" in
+ CYGWIN* )
+ cygwin=true
+ ;;
+ Darwin* )
+ darwin=true
+ ;;
+ MINGW* )
+ msys=true
+ ;;
+ NONSTOP* )
+ nonstop=true
+ ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD="java"
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
+ MAX_FD_LIMIT=`ulimit -H -n`
+ if [ $? -eq 0 ] ; then
+ if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+ MAX_FD="$MAX_FD_LIMIT"
+ fi
+ ulimit -n $MAX_FD
+ if [ $? -ne 0 ] ; then
+ warn "Could not set maximum file descriptor limit: $MAX_FD"
+ fi
+ else
+ warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+ fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+ GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+ APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+ CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+ JAVACMD=`cygpath --unix "$JAVACMD"`
+
+ # We build the pattern for arguments to be converted via cygpath
+ ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+ SEP=""
+ for dir in $ROOTDIRSRAW ; do
+ ROOTDIRS="$ROOTDIRS$SEP$dir"
+ SEP="|"
+ done
+ OURCYGPATTERN="(^($ROOTDIRS))"
+ # Add a user-defined pattern to the cygpath arguments
+ if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+ OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+ fi
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ i=0
+ for arg in "$@" ; do
+ CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+ CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
+
+ if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
+ eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+ else
+ eval `echo args$i`="\"$arg\""
+ fi
+ i=`expr $i + 1`
+ done
+ case $i in
+ 0) set -- ;;
+ 1) set -- "$args0" ;;
+ 2) set -- "$args0" "$args1" ;;
+ 3) set -- "$args0" "$args1" "$args2" ;;
+ 4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+ 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+ 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+ 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+ 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+ 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+ esac
+fi
+
+# Escape application args
+save () {
+ for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+ echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..107acd32c
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/install_and_run.bat b/install_and_run.bat
new file mode 100644
index 000000000..21a692d74
--- /dev/null
+++ b/install_and_run.bat
@@ -0,0 +1,95 @@
+@echo off
+chcp 65001 >nul
+echo ========================================
+echo Установка и запуск Android Contacts
+echo ========================================
+echo.
+
+REM Проверка наличия ANDROID_HOME
+if not defined ANDROID_HOME (
+ echo [ОШИБКА] Переменная окружения ANDROID_HOME не установлена!
+ echo Пожалуйста, установите Android SDK и настройте переменную ANDROID_HOME
+ echo Например: setx ANDROID_HOME "C:\Users\YourName\AppData\Local\Android\Sdk"
+ pause
+ exit /b 1
+)
+
+echo [1/5] Проверка окружения...
+echo ANDROID_HOME: %ANDROID_HOME%
+echo.
+
+REM Проверка наличия adb
+if not exist "%ANDROID_HOME%\platform-tools\adb.exe" (
+ echo [ОШИБКА] ADB не найден в %ANDROID_HOME%\platform-tools\
+ echo Установите Android SDK Platform-Tools через Android Studio
+ pause
+ exit /b 1
+)
+
+echo [2/5] Проверка эмулятора...
+"%ANDROID_HOME%\platform-tools\adb.exe" devices
+echo.
+
+REM Подсчет количества устройств
+for /f "skip=1 tokens=2" %%i in ('"%ANDROID_HOME%\platform-tools\adb.exe" devices') do (
+ if "%%i"=="device" (
+ set DEVICE_FOUND=1
+ goto :device_found
+ )
+)
+
+:device_found
+if not defined DEVICE_FOUND (
+ echo [ВНИМАНИЕ] Эмулятор не запущен!
+ echo.
+ echo Запускаю список доступных эмуляторов...
+ if exist "%ANDROID_HOME%\emulator\emulator.exe" (
+ "%ANDROID_HOME%\emulator\emulator.exe" -list-avds
+ echo.
+ echo Для запуска эмулятора используйте команду:
+ echo emulator -avd [имя_эмулятора]
+ echo.
+ echo Или запустите эмулятор вручную через Android Studio
+ echo После запуска эмулятора повторите запуск этого скрипта
+ ) else (
+ echo [ОШИБКА] Эмулятор не найден в %ANDROID_HOME%\emulator\
+ echo Установите Android Emulator через Android Studio SDK Manager
+ )
+ pause
+ exit /b 1
+)
+
+echo [3/5] Очистка предыдущей сборки...
+call gradlew.bat clean
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось выполнить очистку проекта
+ pause
+ exit /b 1
+)
+echo.
+
+echo [4/5] Сборка и установка приложения...
+echo Это может занять несколько минут при первом запуске...
+call gradlew.bat installDebug
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось собрать и установить приложение
+ echo Проверьте логи выше для деталей
+ pause
+ exit /b 1
+)
+echo.
+
+echo [5/5] Запуск приложения на эмуляторе...
+"%ANDROID_HOME%\platform-tools\adb.exe" shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось запустить приложение
+ pause
+ exit /b 1
+)
+
+echo.
+echo ========================================
+echo УСПЕХ! Приложение установлено и запущено
+echo ========================================
+echo.
+pause
diff --git a/install_and_run_fixed.bat b/install_and_run_fixed.bat
new file mode 100644
index 000000000..10d8e91e1
--- /dev/null
+++ b/install_and_run_fixed.bat
@@ -0,0 +1,91 @@
+@echo off
+setlocal
+chcp 65001 >nul
+echo ========================================
+echo Установка и запуск Android Contacts
+echo ========================================
+echo.
+
+REM Устанавливаем ANDROID_HOME, если он не определен
+if not defined ANDROID_HOME (
+ echo Установка ANDROID_HOME...
+ set ANDROID_HOME=C:\Users\User\AppData\Local\Android\Sdk
+)
+
+echo [1/5] Проверка окружения...
+echo ANDROID_HOME: %ANDROID_HOME%
+echo.
+
+REM Проверка наличия adb
+if not exist "%ANDROID_HOME%\platform-tools\adb.exe" (
+ echo [ОШИБКА] ADB не найден в %ANDROID_HOME%\platform-tools\
+ echo Установите Android SDK Platform-Tools через Android Studio
+ pause
+ exit /b 1
+)
+
+echo [2/5] Проверка эмулятора...
+"%ANDROID_HOME%\platform-tools\adb.exe" devices
+echo.
+
+REM Подсчет количества устройств
+set DEVICE_FOUND=
+for /f "skip=1 tokens=1" %%i in ('"%ANDROID_HOME%\platform-tools\adb.exe" devices') do (
+ if "%%i"=="emulator-5554" set DEVICE_FOUND=1
+ if "%%i"=="device" set DEVICE_FOUND=1
+)
+
+if not defined DEVICE_FOUND (
+ echo [ВНИМАНИЕ] Эмулятор не запущен!
+ echo.
+ echo Запускаю список доступных эмуляторов...
+ if exist "%ANDROID_HOME%\emulator\emulator.exe" (
+ "%ANDROID_HOME%\emulator\emulator.exe" -list-avds
+ echo.
+ echo Для запуска эмулятора используйте команду:
+ echo emulator -avd [имя_эмулятора]
+ echo.
+ echo Или запустите эмулятор вручную через Android Studio
+ echo После запуска эмулятора повторите запуск этого скрипта
+ ) else (
+ echo [ОШИБКА] Эмулятор не найден в %ANDROID_HOME%\emulator\
+ echo Установите Android Emulator через Android Studio SDK Manager
+ )
+ pause
+ exit /b 1
+)
+
+echo [3/5] Очистка предыдущей сборки...
+call gradlew.bat clean
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось выполнить очистку проекта
+ pause
+ exit /b 1
+)
+echo.
+
+echo [4/5] Сборка и установка приложения...
+echo Это может занять несколько минут при первом запуске...
+call gradlew.bat installDebug
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось собрать и установить приложение
+ echo Проверьте логи выше для деталей
+ pause
+ exit /b 1
+)
+echo.
+
+echo [5/5] Запуск приложения на эмуляторе...
+"%ANDROID_HOME%\platform-tools\adb.exe" shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось запустить приложение
+ pause
+ exit /b 1
+)
+
+echo.
+echo ========================================
+echo УСПЕХ! Приложение установлено и запущено
+echo ========================================
+echo.
+pause
\ No newline at end of file
diff --git a/install_and_run_new.bat b/install_and_run_new.bat
new file mode 100644
index 000000000..fdcfce58f
--- /dev/null
+++ b/install_and_run_new.bat
@@ -0,0 +1,90 @@
+@echo off
+chcp 65001 >nul
+echo ========================================
+echo Установка и запуск Android Contacts
+echo ========================================
+echo.
+
+REM Устанавливаем ANDROID_HOME, если он не определен
+if not defined ANDROID_HOME (
+ echo Установка ANDROID_HOME...
+ set ANDROID_HOME=C:\Users\User\AppData\Local\Android\Sdk
+)
+
+echo [1/5] Проверка окружения...
+echo ANDROID_HOME: %ANDROID_HOME%
+echo.
+
+REM Проверка наличия adb
+if not exist "%ANDROID_HOME%\platform-tools\adb.exe" (
+ echo [ОШИБКА] ADB не найден в %ANDROID_HOME%\platform-tools\
+ echo Установите Android SDK Platform-Tools через Android Studio
+ pause
+ exit /b 1
+)
+
+echo [2/5] Проверка эмулятора...
+"%ANDROID_HOME%\platform-tools\adb.exe" devices
+echo.
+
+REM Подсчет количества устройств
+set DEVICE_FOUND=
+for /f "skip=1 tokens=1" %%i in ('"%ANDROID_HOME%\platform-tools\adb.exe" devices') do (
+ if "%%i"=="emulator-5554" set DEVICE_FOUND=1
+ if "%%i"=="device" set DEVICE_FOUND=1
+)
+
+if not defined DEVICE_FOUND (
+ echo [ВНИМАНИЕ] Эмулятор не запущен!
+ echo.
+ echo Запускаю список доступных эмуляторов...
+ if exist "%ANDROID_HOME%\emulator\emulator.exe" (
+ "%ANDROID_HOME%\emulator\emulator.exe" -list-avds
+ echo.
+ echo Для запуска эмулятора используйте команду:
+ echo emulator -avd [имя_эмулятора]
+ echo.
+ echo Или запустите эмулятор вручную через Android Studio
+ echo После запуска эмулятора повторите запуск этого скрипта
+ ) else (
+ echo [ОШИБКА] Эмулятор не найден в %ANDROID_HOME%\emulator\
+ echo Установите Android Emulator через Android Studio SDK Manager
+ )
+ pause
+ exit /b 1
+)
+
+echo [3/5] Очистка предыдущей сборки...
+call gradlew.bat clean
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось выполнить очистку проекта
+ pause
+ exit /b 1
+)
+echo.
+
+echo [4/5] Сборка и установка приложения...
+echo Это может занять несколько минут при первом запуске...
+call gradlew.bat installDebug
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось собрать и установить приложение
+ echo Проверьте логи выше для деталей
+ pause
+ exit /b 1
+)
+echo.
+
+echo [5/5] Запуск приложения на эмуляторе...
+"%ANDROID_HOME%\platform-tools\adb.exe" shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity
+if errorlevel 1 (
+ echo [ОШИБКА] Не удалось запустить приложение
+ pause
+ exit /b 1
+)
+
+echo.
+echo ========================================
+echo УСПЕХ! Приложение установлено и запущено
+echo ========================================
+echo.
+pause
\ No newline at end of file
diff --git a/run_with_log.bat b/run_with_log.bat
new file mode 100644
index 000000000..00618b354
--- /dev/null
+++ b/run_with_log.bat
@@ -0,0 +1,50 @@
+@echo off
+set JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-17.0.16.8-hotspot
+set ANDROID_HOME=C:\Users\shche\AppData\Local\Android\Sdk
+set PATH=%JAVA_HOME%\bin;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\emulator;%PATH%
+
+set LOGFILE=build_log.txt
+
+echo =============================================== > %LOGFILE%
+echo Build started at %date% %time% >> %LOGFILE%
+echo =============================================== >> %LOGFILE%
+echo. >> %LOGFILE%
+
+echo Checking Java... >> %LOGFILE%
+java -version 2>> %LOGFILE%
+echo. >> %LOGFILE%
+
+echo Checking connected devices... >> %LOGFILE%
+adb devices >> %LOGFILE% 2>&1
+echo. >> %LOGFILE%
+
+echo Building project... >> %LOGFILE%
+gradlew.bat assembleDebug >> %LOGFILE% 2>&1
+if %errorlevel% neq 0 (
+ echo BUILD FAILED! >> %LOGFILE%
+ echo Build failed! Check build_log.txt
+ pause
+ exit /b 1
+)
+
+echo. >> %LOGFILE%
+echo Installing APK... >> %LOGFILE%
+adb install -r app\build\outputs\apk\debug\app-debug.apk >> %LOGFILE% 2>&1
+if %errorlevel% neq 0 (
+ echo INSTALL FAILED! >> %LOGFILE%
+ echo Installation failed! Check build_log.txt
+ pause
+ exit /b 1
+)
+
+echo. >> %LOGFILE%
+echo Starting app... >> %LOGFILE%
+adb shell am start -n ru.yandex.practicum.contacts/.presentation.main.MainActivity >> %LOGFILE% 2>&1
+
+echo =============================================== >> %LOGFILE%
+echo Completed at %date% %time% >> %LOGFILE%
+echo =============================================== >> %LOGFILE%
+
+echo SUCCESS! App is running on emulator.
+echo Check build_log.txt for details.
+pause
diff --git a/settings.gradle b/settings.gradle
new file mode 100644
index 000000000..6aead0bb5
--- /dev/null
+++ b/settings.gradle
@@ -0,0 +1,16 @@
+pluginManagement {
+ repositories {
+ gradlePluginPortal()
+ google()
+ mavenCentral()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+rootProject.name = "Contacts"
+include ':app'
diff --git a/src/main/java/Main.java b/src/main/java/Main.java
index db9356a08..d22692630 100644
--- a/src/main/java/Main.java
+++ b/src/main/java/Main.java
@@ -1,6 +1,88 @@
+import java.util.Scanner;
+
+class Car {
+ public String name;
+ public int speed;
+
+
+ public Car(String name, int speed) {
+ this.name = name;
+ this.speed = speed;
+ }
+ public String getName() {
+ return name;
+ }
+ public int getSpeed() {
+ return speed;
+ }
+}
+
+class Race {
+ private String currentLeader;
+ private int leaderDistance;
+ private static final int time = 24;
+
+ public Race() {
+ currentLeader = "";
+ leaderDistance = 0;
+ }
+ public void checkNewLeader(Car car) {
+ int newDistance = car.getSpeed() * time;
+
+ if (newDistance > leaderDistance) {
+ currentLeader = car.getName();
+ leaderDistance = newDistance;
+ }
+ }
+
+ public String getCurrentLeader() {
+ return currentLeader;
+ }
+ public int getLeaderDistance() {
+ return leaderDistance;
+ }
+}
public class Main {
public static void main(String[] args) {
- System.out.println("Hello world!");
+ Race race = new Race();
+
+ Scanner scanner = new Scanner(System.in);
+
+
+ for (int i = 1; i <= 3; i++) {
+ System.out.print("Введите название машины №" + i + ": ");
+ String name = scanner.nextLine();
+ while (name.isEmpty()) {
+ System.out.println("Ошибка! Название машины не может быть пустым. Попробуйте снова.");
+ System.out.print("Введите название машины №" + i + ": ");
+ name = scanner.nextLine();}
+
+ int speed = 0;
+ boolean isValid = false;
+ while (!isValid) {
+ try {
+ System.out.print("Введите скорость машины №" + i + " (0-250 км/ч): ");
+ speed = Integer.parseInt(scanner.nextLine());
+
+
+ if (speed >= 0 && speed <= 250) {
+ isValid = true;
+ } else {
+ System.out.println("Ошибка! Скорость должна быть в диапазоне 0-250 км/ч");
+ }
+ } catch (NumberFormatException e) {
+
+ System.out.println("Ошибка! Введите целое число");
+ }
+ }
+
+ Car car = new Car(name, speed);
+
+ race.checkNewLeader(car);
+ }
+
+ System.out.println("Победитель гонки: " + race.getCurrentLeader());
+ System.out.println("Пройденная дистанция: " + race.getLeaderDistance() + " км");
}
}
\ No newline at end of file