Mikhail Balykin 3 months ago
commit
e10d3d4bbc
100 changed files with 5083 additions and 0 deletions
  1. 15 0
      .gitignore
  2. 1 0
      app/.gitignore
  3. BIN
      app/aars/abto_android_voip_sdk.aar
  4. 125 0
      app/build.gradle
  5. 21 0
      app/proguard-rules.pro
  6. 103 0
      app/src/main/AndroidManifest.xml
  7. 29 0
      app/src/main/java/ru/mephi/voip/KoinModule.kt
  8. 296 0
      app/src/main/java/ru/mephi/voip/MainActivity.kt
  9. 253 0
      app/src/main/java/ru/mephi/voip/NavigationExtensions.kt
  10. 41 0
      app/src/main/java/ru/mephi/voip/VoIPApplication.kt
  11. 357 0
      app/src/main/java/ru/mephi/voip/call/CallActivity.kt
  12. 33 0
      app/src/main/java/ru/mephi/voip/call/CallViewModel.kt
  13. 14 0
      app/src/main/java/ru/mephi/voip/call/CallViewModelProviderFactory.kt
  14. 65 0
      app/src/main/java/ru/mephi/voip/call/SoundManager.kt
  15. 31 0
      app/src/main/java/ru/mephi/voip/call/abto/AbtoApp.kt
  16. 7 0
      app/src/main/java/ru/mephi/voip/call/abto/AccountStatus.kt
  17. 24 0
      app/src/main/java/ru/mephi/voip/call/abto/AppInBackgroundHandler.kt
  18. 129 0
      app/src/main/java/ru/mephi/voip/call/abto/CallEventsReceiver.kt
  19. 20 0
      app/src/main/java/ru/mephi/voip/data/database/DatabaseViewModel.kt
  20. 11 0
      app/src/main/java/ru/mephi/voip/data/database/RecordsDao.kt
  21. 11 0
      app/src/main/java/ru/mephi/voip/data/database/calls/CallDatabase.kt
  22. 14 0
      app/src/main/java/ru/mephi/voip/data/database/calls/CallRecord.kt
  23. 22 0
      app/src/main/java/ru/mephi/voip/data/database/calls/CallRecordsDao.kt
  24. 27 0
      app/src/main/java/ru/mephi/voip/data/database/calls/LocalDateTimeConverter.kt
  25. 9 0
      app/src/main/java/ru/mephi/voip/data/database/search/SearchDatabase.kt
  26. 12 0
      app/src/main/java/ru/mephi/voip/data/database/search/SearchRecord.kt
  27. 25 0
      app/src/main/java/ru/mephi/voip/data/database/search/SearchRecordsDao.kt
  28. 6 0
      app/src/main/java/ru/mephi/voip/data/model/Account.kt
  29. 19 0
      app/src/main/java/ru/mephi/voip/data/model/Appointment.kt
  30. 9 0
      app/src/main/java/ru/mephi/voip/data/model/NameItem.kt
  31. 15 0
      app/src/main/java/ru/mephi/voip/data/model/UnitM.kt
  32. 62 0
      app/src/main/java/ru/mephi/voip/data/network/ConnectionStateMonitor.kt
  33. 45 0
      app/src/main/java/ru/mephi/voip/data/network/CustomSnackBar.kt
  34. 64 0
      app/src/main/java/ru/mephi/voip/data/network/NetworkSensingBaseActivity.kt
  35. 59 0
      app/src/main/java/ru/mephi/voip/data/network/NetworkSensingBaseFragment.kt
  36. 12 0
      app/src/main/java/ru/mephi/voip/data/network/api/ApiHelper.kt
  37. 19 0
      app/src/main/java/ru/mephi/voip/data/network/api/BaseApiService.kt
  38. 44 0
      app/src/main/java/ru/mephi/voip/data/network/api/KtorApiService.kt
  39. 54 0
      app/src/main/java/ru/mephi/voip/data/network/api/KtorClientBuilder.kt
  40. 12 0
      app/src/main/java/ru/mephi/voip/data/network/util/Resource.kt
  41. 7 0
      app/src/main/java/ru/mephi/voip/data/network/util/Status.kt
  42. 42 0
      app/src/main/java/ru/mephi/voip/data/repository/CatalogCacheRepository.kt
  43. 31 0
      app/src/main/java/ru/mephi/voip/data/repository/CatalogRepository.kt
  44. 17 0
      app/src/main/java/ru/mephi/voip/data/utils/Animation.kt
  45. 39 0
      app/src/main/java/ru/mephi/voip/data/utils/RoundedTransformation.kt
  46. 5 0
      app/src/main/java/ru/mephi/voip/data/utils/SearchType.kt
  47. 205 0
      app/src/main/java/ru/mephi/voip/data/utils/Utils.kt
  48. 187 0
      app/src/main/java/ru/mephi/voip/ui/calls/CallerFragment.kt
  49. 24 0
      app/src/main/java/ru/mephi/voip/ui/calls/CallerViewModel.kt
  50. 13 0
      app/src/main/java/ru/mephi/voip/ui/calls/CallerViewModelProviderFactory.kt
  51. 92 0
      app/src/main/java/ru/mephi/voip/ui/calls/adapter/CallHistoryAdapter.kt
  52. 103 0
      app/src/main/java/ru/mephi/voip/ui/calls/adapter/SwipeToDeleteCallback.kt
  53. 93 0
      app/src/main/java/ru/mephi/voip/ui/calls/numpad/NumPad.kt
  54. 37 0
      app/src/main/java/ru/mephi/voip/ui/calls/numpad/NumPadClickListener.kt
  55. 44 0
      app/src/main/java/ru/mephi/voip/ui/calls/numpad/NumPadLogic.kt
  56. 76 0
      app/src/main/java/ru/mephi/voip/ui/catalog/AppointmentKodein.kt
  57. 354 0
      app/src/main/java/ru/mephi/voip/ui/catalog/CatalogFragment.kt
  58. 101 0
      app/src/main/java/ru/mephi/voip/ui/catalog/CatalogViewModel.kt
  59. 14 0
      app/src/main/java/ru/mephi/voip/ui/catalog/CatalogViewModelProviderFactory.kt
  60. 52 0
      app/src/main/java/ru/mephi/voip/ui/catalog/UnitMKodeIn.kt
  61. 8 0
      app/src/main/java/ru/mephi/voip/ui/catalog/adapter/BaseViewHolder.kt
  62. 49 0
      app/src/main/java/ru/mephi/voip/ui/catalog/adapter/BreadcrumbsAdapter.kt
  63. 14 0
      app/src/main/java/ru/mephi/voip/ui/catalog/adapter/CatalogSuggestionProvider.kt
  64. 308 0
      app/src/main/java/ru/mephi/voip/ui/catalog/adapter/DataAdapter.kt
  65. 49 0
      app/src/main/java/ru/mephi/voip/ui/profile/BottomSheetWithClose.kt
  66. 467 0
      app/src/main/java/ru/mephi/voip/ui/profile/ProfileFragment.kt
  67. 97 0
      app/src/main/java/ru/mephi/voip/ui/profile/ProfileViewModel.kt
  68. 18 0
      app/src/main/java/ru/mephi/voip/ui/profile/Shape.kt
  69. 107 0
      app/src/main/java/ru/mephi/voip/ui/settings/SettingsFragment.kt
  70. 6 0
      app/src/main/res/anim/slide_in_left.xml
  71. 6 0
      app/src/main/res/anim/slide_out_right.xml
  72. 30 0
      app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  73. 6 0
      app/src/main/res/drawable/bottom_nav_icon_color_selector.xml
  74. 13 0
      app/src/main/res/drawable/divider.xml
  75. 10 0
      app/src/main/res/drawable/ic_account_circle_24.xml
  76. 9 0
      app/src/main/res/drawable/ic_backspace_24.xml
  77. 10 0
      app/src/main/res/drawable/ic_baseline_call_24.xml
  78. 5 0
      app/src/main/res/drawable/ic_baseline_call_made_24.xml
  79. 5 0
      app/src/main/res/drawable/ic_baseline_call_received_24.xml
  80. 10 0
      app/src/main/res/drawable/ic_baseline_delete_24.xml
  81. 10 0
      app/src/main/res/drawable/ic_baseline_delete_sweep_24.xml
  82. 10 0
      app/src/main/res/drawable/ic_baseline_dialer_sip_24.xml
  83. 10 0
      app/src/main/res/drawable/ic_baseline_dialer_sip_24_white.xml
  84. 5 0
      app/src/main/res/drawable/ic_baseline_dialpad_24.xml
  85. 10 0
      app/src/main/res/drawable/ic_baseline_error_24.xml
  86. 5 0
      app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml
  87. 5 0
      app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml
  88. 5 0
      app/src/main/res/drawable/ic_baseline_mail_outline_24.xml
  89. 5 0
      app/src/main/res/drawable/ic_baseline_phone_24.xml
  90. 10 0
      app/src/main/res/drawable/ic_baseline_search_24.xml
  91. 10 0
      app/src/main/res/drawable/ic_baseline_settings_24.xml
  92. 9 0
      app/src/main/res/drawable/ic_home_black_24dp.xml
  93. 74 0
      app/src/main/res/drawable/ic_launcher_background.xml
  94. 9 0
      app/src/main/res/drawable/ic_toggle_off.xml
  95. 24 0
      app/src/main/res/drawable/ic_toggle_on.xml
  96. 4 0
      app/src/main/res/drawable/ic_user_default_icon.xml
  97. BIN
      app/src/main/res/drawable/icon_call_end.png
  98. BIN
      app/src/main/res/drawable/icon_mic_off.png
  99. BIN
      app/src/main/res/drawable/icon_volume.png
  100. 0 0
      app/src/main/res/drawable/img_romanov.png

+ 15 - 0
.gitignore

@@ -0,0 +1,15 @@
+*.iml
+.gradle
+/local.properties
+/.idea/caches
+/.idea/libraries
+/.idea/modules.xml
+/.idea/workspace.xml
+/.idea/navEditor.xml
+/.idea/assetWizardSettings.xml
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties

+ 1 - 0
app/.gitignore

@@ -0,0 +1 @@
+/build

BIN
app/aars/abto_android_voip_sdk.aar


+ 125 - 0
app/build.gradle

@@ -0,0 +1,125 @@
+plugins {
+    id 'com.android.application'
+    id 'kotlin-android'
+    id 'org.jetbrains.kotlin.plugin.serialization'
+    id 'androidx.navigation.safeargs'
+    id "org.jetbrains.kotlin.kapt"
+}
+
+android {
+    compileSdk 31
+
+    defaultConfig {
+        applicationId "ru.mephi.voip"
+        minSdk 21
+        targetSdk 31
+        versionCode 1
+        versionName "1.0"
+
+        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
+    }
+
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        }
+    }
+
+    buildFeatures {
+        viewBinding true
+        dataBinding true
+        compose true
+    }
+
+    compileOptions {
+        sourceCompatibility JavaVersion.VERSION_1_8
+        targetCompatibility JavaVersion.VERSION_1_8
+    }
+
+    composeOptions {
+        kotlinCompilerExtensionVersion "1.0.3" // require kotlin-gradle-plugin 1.5.30
+    }
+
+    kotlinOptions {
+        jvmTarget = '1.8'
+    }
+}
+
+dependencies {
+    implementation 'androidx.core:core-ktx:1.6.0'
+    implementation 'androidx.appcompat:appcompat:1.3.1'
+    implementation 'com.google.android.material:material:1.4.0'
+    implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
+
+    implementation  "org.jetbrains.kotlinx:kotlinx-serialization-core:1.3.0"
+    implementation  "org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.0"
+
+    implementation  "io.coil-kt:coil:1.3.2"
+    implementation  "io.coil-kt:coil-compose:1.3.2"
+
+    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.5.30"
+
+//    implementation  "io.insert-koin:koin-core:3.1.2"
+    implementation  "io.insert-koin:koin-android:3.1.2"
+    implementation  "io.insert-koin:koin-androidx-compose:3.1.2"
+
+    implementation  "androidx.room:room-ktx:2.3.0"
+    implementation 'androidx.compose.material:material:1.0.4'
+    annotationProcessor  "androidx.room:room-compiler:2.3.0"
+    coreLibraryDesugaring  "com.android.tools:desugar_jdk_libs:1.1.5"
+
+    implementation  "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0"
+
+
+    implementation "androidx.core:core-ktx:1.6.0" 
+    implementation "androidx.appcompat:appcompat:1.3.1" 
+    implementation "androidx.constraintlayout:constraintlayout:2.1.1" 
+    implementation "com.google.android.material:material:1.4.0" 
+
+    implementation "androidx.recyclerview:recyclerview:1.2.1" 
+    implementation "com.github.xabaras:RecyclerViewSwipeDecorator:1.3" 
+
+    implementation "androidx.navigation:navigation-fragment-ktx:2.3.5" 
+    implementation "androidx.navigation:navigation-ui-ktx:2.3.5" 
+    implementation "androidx.navigation:navigation-runtime-ktx:2.3.5" 
+
+    implementation "androidx.lifecycle:lifecycle-extensions:2.2.0" 
+    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1" 
+    implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.3.1" 
+    implementation "androidx.lifecycle:lifecycle-livedata-ktx:2.3.1" 
+
+    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2" 
+    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2-native-mt" 
+
+    debugImplementation "org.kodein.db:kodein-db-android-debug:0.8.1-beta" 
+    releaseImplementation "org.kodein.db:kodein-db-android:0.8.1-beta" 
+    implementation "org.kodein.db:kodein-db-serializer-kotlinx:0.8.1-beta" 
+
+    implementation "androidx.annotation:annotation:1.2.0" 
+
+    implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.2" 
+
+    implementation "com.squareup.retrofit2:converter-moshi:2.9.0"
+    annotationProcessor "com.squareup.moshi:moshi-kotlin-codegen:1.12.0"
+    implementation "com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.2" 
+
+    implementation "com.jakewharton.timber:timber:5.0.1" 
+
+    implementation "androidx.preference:preference-ktx:1.1.1" 
+
+    implementation "com.squareup.picasso:picasso:2.71828" 
+
+    implementation "com.vmadalin:easypermissions-ktx:1.0.0" 
+    implementation "com.rbddevs.splashy:splashy:1.3.0" 
+    implementation "com.polyak:icon-switch:1.0.0" 
+    implementation "io.ktor:ktor-client-okhttp:1.6.2"  
+    implementation "io.ktor:ktor-client-serialization:1.6.2"  
+    implementation "io.ktor:ktor-client-core:1.6.2"  
+    implementation "io.ktor:ktor-client-json:1.6.2"  
+
+    implementation files("aars/abto_android_voip_sdk.aar")
+    // Unused in KMM
+    implementation "com.squareup.moshi:moshi-kotlin:1.12.0" 
+    
+}

+ 21 - 0
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

+ 103 - 0
app/src/main/AndroidManifest.xml

@@ -0,0 +1,103 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="ru.mephi.voip">
+
+    <!-- Права для сети -->
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <!-- Права для sip -->
+    <uses-permission android:name="android.permission.USE_SIP" />
+    <uses-permission android:name="android.permission.VIBRATE" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+    <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.CONFIGURE_SIP" />
+    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
+
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+
+    <uses-permission android:name="android.permission.VIBRATE" />
+    <uses-permission android:name="android.permission.BLUETOOTH" />
+    <uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
+    <uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
+    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
+
+    <uses-feature
+        android:name="android.software.sip"
+        android:required="true" />
+
+    <!-- Убедимся, что у устройства есть микрофон и оно может звонить по sip. -->
+    <uses-feature
+        android:name="android.software.sip.voip"
+        android:required="true" />
+    <uses-feature
+        android:name="android.hardware.wifi"
+        android:required="true" />
+    <uses-feature
+        android:name="android.hardware.microphone"
+        android:required="true" />
+    <uses-feature
+        android:name="android.hardware.sip.voip"
+        android:required="true" />
+    <!--        android:name="org.abtollc.sdk.AbtoNotificationApplication"-->
+    <application
+        android:requestLegacyExternalStorage="true"
+        android:name=".VoIPApplication"
+        android:allowBackup="true"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher"
+        android:supportsRtl="true"
+        android:theme="@style/AppTheme.NoActionBar">
+
+        <activity
+            android:screenOrientation="portrait"
+            android:name=".MainActivity"
+            android:label="@string/app_name"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name=".call.CallActivity"
+            android:excludeFromRecents="true"
+            android:keepScreenOn="true"
+            android:screenOrientation="portrait" />
+
+        <meta-data
+            android:name="AbtoVoipCallActivity"
+            android:value="ru.mephi.voip.call.CallActivity" />
+
+        <provider
+            android:name=".ui.catalog.adapter.CatalogSuggestionProvider"
+            android:authorities="ru.mephi.voip.ui.catalog.adapter.CatalogSuggestionProvider" />
+
+        <service
+            android:name="org.abtollc.service.ABTOSipService"
+            android:stopWithTask="false"
+            android:exported="true">
+            <intent-filter>
+                <action android:name="org.abtollc.service.ABTOSipService" />
+                <action android:name="org.abtollc.service.SipConfiguration" />
+            </intent-filter>
+        </service>
+
+        <receiver
+            android:name=".call.abto.CallEventsReceiver"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="ru.mephi.voip.INCOMING_CALL" />
+            </intent-filter>
+        </receiver>
+
+        <provider
+            android:name="org.abtollc.db.DBProvider"
+            android:authorities="ru.mephi.voip.abtodb" />
+        <!--        <meta-data android:name="AbtoVoipAuthority" android:value="ru.mephi.voip" />-->
+    </application>
+
+</manifest>

+ 29 - 0
app/src/main/java/ru/mephi/voip/KoinModule.kt

@@ -0,0 +1,29 @@
+package ru.mephi.voip
+
+import android.content.Context
+import android.content.SharedPreferences
+import android.preference.PreferenceManager
+import org.koin.android.ext.koin.androidApplication
+import org.koin.androidx.viewmodel.dsl.viewModel
+import org.koin.core.qualifier.named
+import org.koin.dsl.module
+import ru.mephi.voip.data.network.api.ApiHelper
+import ru.mephi.voip.data.network.api.KtorApiService
+import ru.mephi.voip.data.repository.CatalogRepository
+import ru.mephi.voip.ui.profile.ProfileViewModel
+
+lateinit var appContext: Context
+
+val koinModule = module {
+    single(named("account_prefs")) { spAccounts() }
+    single { CatalogRepository() }
+    single { ApiHelper() }
+    single { KtorApiService() }
+    viewModel { ProfileViewModel(androidApplication(), get(named("account_prefs"))) }
+}
+
+private fun spAccounts(): SharedPreferences =
+    PreferenceManager.getDefaultSharedPreferences(appContext)
+
+fun getApplicationFilesDirectoryPath(): String =
+    appContext.filesDir.absolutePath

+ 296 - 0
app/src/main/java/ru/mephi/voip/MainActivity.kt

@@ -0,0 +1,296 @@
+package ru.mephi.voip
+
+import android.Manifest
+import android.annotation.TargetApi
+import android.app.AlertDialog
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.pm.PackageManager
+import android.os.Build
+import android.os.Bundle
+import androidx.core.app.ActivityCompat
+import androidx.core.app.NotificationCompat
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.LiveData
+import androidx.navigation.NavController
+import androidx.preference.PreferenceManager
+import com.vmadalin.easypermissions.EasyPermissions
+import org.abtollc.sdk.AbtoApplication
+import org.abtollc.sdk.AbtoPhone
+import org.abtollc.sdk.AbtoPhoneCfg
+import org.abtollc.sdk.OnInitializeListener
+import org.abtollc.sdk.OnInitializeListener.InitializeState
+import org.abtollc.utils.codec.Codec
+import ru.mephi.voip.data.model.Account
+import ru.mephi.voip.data.network.NetworkSensingBaseActivity
+import ru.mephi.voip.data.utils.SIP_DOMAIN
+import ru.mephi.voip.data.utils.getActiveAccount
+import ru.mephi.voip.data.utils.toast
+import ru.mephi.voip.databinding.ActivityMainBinding
+import timber.log.Timber
+
+class MainActivity : NetworkSensingBaseActivity(), OnInitializeListener {
+    private val CHANNEL_ID: String = "CHANNEL"
+    private lateinit var binding: ActivityMainBinding
+    private val REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS = 124
+
+    companion object {
+        lateinit var abtoPhone: AbtoPhone
+    }
+
+    private var account: Account? = null
+
+    private var currentNavController: LiveData<NavController>? = null
+
+    private lateinit var sp: SharedPreferences
+
+    override fun onRestoreInstanceState(savedInstanceState: Bundle) {
+        super.onRestoreInstanceState(savedInstanceState)
+        // Теперь, когда BottomNavigationBar восстановил свое состояние экземпляра.
+        // и его selectItemId, мы можем приступить к настройке
+        // BottomNavigationBar с навигацией
+        setupBottomNavigationBar()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        if (!sp.getBoolean("background_work", false)) {
+            abtoPhone = (application as AbtoApplication).abtoPhone
+            initPhone()
+            initAccount()
+        }
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        sp = PreferenceManager.getDefaultSharedPreferences(this)
+        binding = ActivityMainBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        if (!hasPermissions())
+            requestPermissions()
+
+        if (sp.getBoolean("background_work", false)) {
+            abtoPhone = (application as AbtoApplication).abtoPhone
+            initPhone()
+            initAccount()
+        }
+
+        if (savedInstanceState == null)
+            setupBottomNavigationBar()
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        // При полном закрытии приложения останавливаем Call-сервис
+        if (!sp.getBoolean("background_work", false)) {
+            Timber.d("Foreground work canceled")
+
+//            abtoPhone.stopForeground() // Убираем уведомления, но abto сервис ещё работает
+            abtoPhone.unregister()
+            abtoPhone.destroy()
+        }
+    }
+
+    private fun hasActiveAccount(): Boolean {
+        account = getActiveAccount(sp)
+        return account != null
+    }
+
+    private fun setupBottomNavigationBar() {
+        val navGraphIds = listOf(
+            R.navigation.calls,
+            R.navigation.catalog,
+            R.navigation.profile
+        )
+
+        val navController = binding.bottomNav.setupWithNavController(
+            navGraphIds = navGraphIds,
+            fragmentManager = supportFragmentManager,
+            containerId = R.id.nav_host_container,
+            intent = intent
+        )
+
+        // Whenever the selected controller changes, setup the action bar.
+//        navController.observe(this) { navController ->
+//            setupActionBarWithNavController(navController)
+//        }  // with compose its crashes
+
+        currentNavController = navController
+    }
+
+    private fun addPermission(permissionsList: MutableList<String>, permission: String): Boolean {
+        if (ContextCompat.checkSelfPermission(
+                this,
+                permission
+            ) !== PackageManager.PERMISSION_GRANTED
+        ) {
+            permissionsList.add(permission)
+            return ActivityCompat.shouldShowRequestPermissionRationale(this, permission)
+        }
+        return true
+    }
+
+    @TargetApi(23)
+    override fun onRequestPermissionsResult(
+        requestCode: Int,
+        permissions: Array<String>,
+        grantResults: IntArray
+    ) {
+        when (requestCode) {
+            REQUEST_CODE_ASK_MULTIPLE_PERMISSIONS -> {
+                val perms: MutableMap<String, Int> = HashMap()
+                //Initial
+                perms[Manifest.permission.RECORD_AUDIO] = PackageManager.PERMISSION_GRANTED
+                perms[Manifest.permission.WRITE_EXTERNAL_STORAGE] =
+                    PackageManager.PERMISSION_GRANTED
+                perms[Manifest.permission.USE_SIP] = PackageManager.PERMISSION_GRANTED
+
+                //Fill with results
+                var i = 0
+                while (i < permissions.size) {
+                    perms[permissions[i]] = grantResults[i]
+                    i++
+                }
+
+                //Check for ACCESS_FINE_LOCATION
+                if (perms[Manifest.permission.RECORD_AUDIO] == PackageManager.PERMISSION_GRANTED
+                    && perms[Manifest.permission.WRITE_EXTERNAL_STORAGE] == PackageManager.PERMISSION_GRANTED
+                    && perms[Manifest.permission.USE_SIP] == PackageManager.PERMISSION_GRANTED
+                ) {
+                    // All Permissions Granted
+                    initPhone()
+                } else {
+                    // Permission Denied
+                    toast("Some permissions were denied")
+                    finish()
+                }
+            }
+            else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults)
+        }
+    }
+
+    private fun hasPermissions(): Boolean =
+        EasyPermissions.hasPermissions(
+            this,
+            Manifest.permission.RECORD_AUDIO,
+            Manifest.permission.USE_SIP
+        )
+
+    private fun requestPermissions() {
+        EasyPermissions.requestPermissions(
+            this,
+            "Это приложение требует разрешение на совершение звонков и использование микрофона",
+            1,
+            Manifest.permission.RECORD_AUDIO,
+            Manifest.permission.USE_SIP
+        )
+    }
+
+    // После отклонения входящего вызова из шторки потом не делает исходящий
+    override fun onRestart() {
+        super.onRestart()
+//        initPhone()
+//        initAccount()
+    }
+
+    override fun onSupportNavigateUp(): Boolean {
+        return currentNavController?.value?.navigateUp() ?: false
+    }
+
+    private fun initAccount() {
+        if (abtoPhone.isActive)
+            return
+
+//        val accId = abtoPhone.currentAccountId.toInt()
+//        accExpire = abtoPhone.config.getAccountExpire(accId.toLong())
+
+        val domain = SIP_DOMAIN
+        val account = getActiveAccount(sp)
+        val username = account?.login
+        val password = account?.password
+
+        if (account != null)
+            abtoPhone.config.addAccount(
+                domain,
+                "",
+                username, password, null, "",
+                300,
+                true
+            )
+    }
+
+    private fun initPhone() {
+        if (abtoPhone.isActive)
+            return
+
+        abtoPhone.setInitializeListener(this)
+        //configure phone instance
+        val config = abtoPhone.config
+        for (c in Codec.values()) config.setCodecPriority(c, 0.toShort())
+        config.setCodecPriority(Codec.G729, 78.toShort())
+        config.setCodecPriority(Codec.PCMA, 80.toShort())
+        config.setCodecPriority(Codec.PCMU, 79.toShort())
+        config.setCodecPriority(Codec.H264, 220.toShort())
+        config.setCodecPriority(Codec.H263_1998, 0.toShort())
+        config.setSignallingTransport(AbtoPhoneCfg.SignalingTransportType.UDP) //TCP);//TLS);
+        config.setKeepAliveInterval(AbtoPhoneCfg.SignalingTransportType.UDP, 30)
+        config.sipPort = 0
+
+        config.isUseSRTP = false
+        config.userAgent = abtoPhone.version()
+        config.hangupTimeout = 3000
+        config.enableSipsSchemeUse = false
+        config.isSTUNEnabled = false
+        AbtoPhoneCfg.setLogLevel(7, true)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+            val channel = NotificationChannel(
+                CHANNEL_ID,
+                getString(R.string.app_name),
+                NotificationManager.IMPORTANCE_NONE
+            )
+            val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
+            notificationManager.createNotificationChannel(channel)
+        }
+
+        val intent = Intent(this, MainActivity::class.java)
+        intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+        val pendingIntent = PendingIntent.getActivity(
+            this, 0, intent,
+            PendingIntent.FLAG_CANCEL_CURRENT
+        )
+
+        val mBuilder = NotificationCompat.Builder(this, CHANNEL_ID)
+        mBuilder.setAutoCancel(false)
+        mBuilder.setOngoing(true)
+        mBuilder.setContentIntent(pendingIntent)
+        mBuilder.setContentText("VoIP MEPhI")
+        mBuilder.setSubText("Клиент запущен")
+        mBuilder.setSmallIcon(R.drawable.logo_voip)
+        val notification = mBuilder.build()
+
+        abtoPhone.initialize(false) //start service in 'sticky' mode - when app removed from recent service will be restarted automatically
+        abtoPhone.initializeForeground(notification) //start service in foreground mode
+    }
+
+    override fun onInitializeState(state: InitializeState?, message: String?) {
+        when (state) {
+            InitializeState.START, InitializeState.INFO, InitializeState.WARNING -> {
+            }
+            InitializeState.FAIL -> AlertDialog.Builder(this)
+                .setTitle("Error")
+                .setMessage(message)
+                .setPositiveButton("Ok") { dlg, _ -> dlg.dismiss() }.create().show()
+            InitializeState.SUCCESS -> {
+//                startNextActivity()
+            }
+            else -> {
+            }
+        }
+    }
+}

+ 253 - 0
app/src/main/java/ru/mephi/voip/NavigationExtensions.kt

@@ -0,0 +1,253 @@
+/*
+ * Copyright 2019, The Android Open Source Project
+ *
+ * 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
+ *
+ *      http://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.
+ */
+
+package ru.mephi.voip
+
+import android.content.Intent
+import android.util.SparseArray
+import androidx.core.util.forEach
+import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.navigation.NavController
+import androidx.navigation.fragment.NavHostFragment
+import com.google.android.material.bottomnavigation.BottomNavigationView
+
+/**
+ * Manages the various graphs needed for a [BottomNavigationView].
+ *
+ * This sample is a workaround until the Navigation Component supports multiple back stacks.
+ */
+fun BottomNavigationView.setupWithNavController(
+    navGraphIds: List<Int>,
+    fragmentManager: FragmentManager,
+    containerId: Int,
+    intent: Intent
+): LiveData<NavController> {
+
+    // Map of tags
+    val graphIdToTagMap = SparseArray<String>()
+    // Result. Mutable live data with the selected controlled
+    val selectedNavController = MutableLiveData<NavController>()
+
+    var firstFragmentGraphId = 0
+
+    // First create a NavHostFragment for each NavGraph ID
+    navGraphIds.forEachIndexed { index, navGraphId ->
+        val fragmentTag = getFragmentTag(index)
+
+        // Find or create the Navigation host fragment
+        val navHostFragment = obtainNavHostFragment(
+            fragmentManager,
+            fragmentTag,
+            navGraphId,
+            containerId
+        )
+
+        // Obtain its id
+        val graphId = navHostFragment.navController.graph.id
+
+        if (index == 0) {
+            firstFragmentGraphId = graphId
+        }
+
+        // Save to the map
+        graphIdToTagMap[graphId] = fragmentTag
+
+        // Attach or detach nav host fragment depending on whether it's the selected item.
+        if (this.selectedItemId == graphId) {
+            // Update livedata with the selected graph
+            selectedNavController.value = navHostFragment.navController
+            attachNavHostFragment(fragmentManager, navHostFragment, index == 0)
+        } else {
+            detachNavHostFragment(fragmentManager, navHostFragment)
+        }
+    }
+
+    // Now connect selecting an item with swapping Fragments
+    var selectedItemTag = graphIdToTagMap[this.selectedItemId]
+    val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId]
+    var isOnFirstFragment = selectedItemTag == firstFragmentTag
+
+    // When a navigation item is selected
+    setOnNavigationItemSelectedListener { item ->
+        // Don't do anything if the state is state has already been saved.
+        if (fragmentManager.isStateSaved) {
+            false
+        } else {
+            val newlySelectedItemTag = graphIdToTagMap[item.itemId]
+            if (selectedItemTag != newlySelectedItemTag) {
+                // Pop everything above the first fragment (the "fixed start destination")
+                fragmentManager.popBackStack(
+                    firstFragmentTag,
+                    FragmentManager.POP_BACK_STACK_INCLUSIVE
+                )
+                val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
+                        as NavHostFragment
+
+                // Exclude the first fragment tag because it's always in the back stack.
+                if (firstFragmentTag != newlySelectedItemTag) {
+                    // Commit a transaction that cleans the back stack and adds the first fragment
+                    // to it, creating the fixed started destination.
+                    fragmentManager.beginTransaction()
+                        .setCustomAnimations(
+                            androidx.navigation.ui.R.anim.nav_default_enter_anim,
+                            androidx.navigation.ui.R.anim.nav_default_exit_anim,
+                            androidx.navigation.ui.R.anim.nav_default_pop_enter_anim,
+                            androidx.navigation.ui.R.anim.nav_default_pop_exit_anim
+                        )
+                        .attach(selectedFragment)
+                        .setPrimaryNavigationFragment(selectedFragment)
+                        .apply {
+                            // Detach all other Fragments
+                            graphIdToTagMap.forEach { _, fragmentTagIter ->
+                                if (fragmentTagIter != newlySelectedItemTag) {
+                                    detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!)
+                                }
+                            }
+                        }
+                        .addToBackStack(firstFragmentTag)
+                        .setReorderingAllowed(true)
+                        .commit()
+                }
+                selectedItemTag = newlySelectedItemTag
+                isOnFirstFragment = selectedItemTag == firstFragmentTag
+                selectedNavController.value = selectedFragment.navController
+                true
+            } else {
+                false
+            }
+        }
+    }
+
+    // Optional: on item reselected, pop back stack to the destination of the graph
+    setupItemReselected(graphIdToTagMap, fragmentManager)
+
+    // Handle deep link
+    setupDeepLinks(navGraphIds, fragmentManager, containerId, intent)
+
+    // Finally, ensure that we update our BottomNavigationView when the back stack changes
+    fragmentManager.addOnBackStackChangedListener {
+        if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) {
+            this.selectedItemId = firstFragmentGraphId
+        }
+
+        // Reset the graph if the currentDestination is not valid (happens when the back
+        // stack is popped after using the back button).
+        selectedNavController.value?.let { controller ->
+            if (controller.currentDestination == null) {
+                controller.navigate(controller.graph.id)
+            }
+        }
+    }
+    return selectedNavController
+}
+
+private fun BottomNavigationView.setupDeepLinks(
+    navGraphIds: List<Int>,
+    fragmentManager: FragmentManager,
+    containerId: Int,
+    intent: Intent
+) {
+    navGraphIds.forEachIndexed { index, navGraphId ->
+        val fragmentTag = getFragmentTag(index)
+
+        // Find or create the Navigation host fragment
+        val navHostFragment = obtainNavHostFragment(
+            fragmentManager,
+            fragmentTag,
+            navGraphId,
+            containerId
+        )
+        // Handle Intent
+        if (navHostFragment.navController.handleDeepLink(intent)
+            && selectedItemId != navHostFragment.navController.graph.id
+        ) {
+            this.selectedItemId = navHostFragment.navController.graph.id
+        }
+    }
+}
+
+private fun BottomNavigationView.setupItemReselected(
+    graphIdToTagMap: SparseArray<String>,
+    fragmentManager: FragmentManager
+) {
+    setOnNavigationItemReselectedListener { item ->
+        val newlySelectedItemTag = graphIdToTagMap[item.itemId]
+        val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag)
+                as NavHostFragment
+        val navController = selectedFragment.navController
+        // Pop the back stack to the start destination of the current navController graph
+        navController.popBackStack(
+            navController.graph.startDestination, false
+        )
+    }
+}
+
+private fun detachNavHostFragment(
+    fragmentManager: FragmentManager,
+    navHostFragment: NavHostFragment
+) {
+    fragmentManager.beginTransaction()
+        .detach(navHostFragment)
+        .commitNow()
+}
+
+private fun attachNavHostFragment(
+    fragmentManager: FragmentManager,
+    navHostFragment: NavHostFragment,
+    isPrimaryNavFragment: Boolean
+) {
+    fragmentManager.beginTransaction()
+        .attach(navHostFragment)
+        .apply {
+            if (isPrimaryNavFragment) {
+                setPrimaryNavigationFragment(navHostFragment)
+            }
+        }
+        .commitNow()
+
+}
+
+private fun obtainNavHostFragment(
+    fragmentManager: FragmentManager,
+    fragmentTag: String,
+    navGraphId: Int,
+    containerId: Int
+): NavHostFragment {
+    // If the Nav Host fragment exists, return it
+    val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment?
+    existingFragment?.let { return it }
+
+    // Otherwise, create it and return it.
+    val navHostFragment = NavHostFragment.create(navGraphId)
+    fragmentManager.beginTransaction()
+        .add(containerId, navHostFragment, fragmentTag)
+        .commitNow()
+    return navHostFragment
+}
+
+private fun FragmentManager.isOnBackStack(backStackName: String): Boolean {
+    val backStackCount = backStackEntryCount
+    for (index in 0 until backStackCount) {
+        if (getBackStackEntryAt(index).name == backStackName) {
+            return true
+        }
+    }
+    return false
+}
+
+private fun getFragmentTag(index: Int) = "bottomNavigation#$index"

+ 41 - 0
app/src/main/java/ru/mephi/voip/VoIPApplication.kt

@@ -0,0 +1,41 @@
+package ru.mephi.voip
+
+import androidx.room.Room
+import org.koin.android.ext.koin.androidContext
+import org.koin.core.context.GlobalContext.startKoin
+import ru.mephi.voip.call.abto.AbtoApp
+import ru.mephi.voip.data.database.calls.CallDatabase
+import ru.mephi.voip.data.database.search.SearchDatabase
+import timber.log.Timber
+
+class VoIPApplication : AbtoApp() {
+    override fun onCreate() {
+        super.onCreate()
+
+        appContext = this
+
+        startKoin {
+            androidContext(this@VoIPApplication)
+            modules(koinModule)
+        }
+        if (BuildConfig.DEBUG) {
+            Timber.plant(Timber.DebugTree())
+        }
+    }
+
+    val callDatabase: CallDatabase by lazy {
+        Room.databaseBuilder(
+            this,
+            CallDatabase::class.java,
+            "database.db"
+        ).allowMainThreadQueries().build()
+    }
+
+    val searchDatabase: SearchDatabase by lazy {
+        Room.databaseBuilder(
+            this,
+            SearchDatabase::class.java,
+            "search_database.db"
+        ).allowMainThreadQueries().build()
+    }
+}

+ 357 - 0
app/src/main/java/ru/mephi/voip/call/CallActivity.kt

@@ -0,0 +1,357 @@
+package ru.mephi.voip.call
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Bundle
+import android.os.Handler
+import android.os.PowerManager
+import android.os.PowerManager.WakeLock
+import android.os.RemoteException
+import android.view.View
+import android.view.WindowManager
+import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ViewModelProvider
+import ru.mephi.voip.data.network.util.Status
+import com.squareup.picasso.Picasso
+import org.abtollc.sdk.*
+import org.abtollc.sdk.OnCallHeldListener.HoldState
+import org.abtollc.sdk.OnInitializeListener.InitializeState
+import ru.mephi.voip.R
+import ru.mephi.voip.data.network.api.KtorClientBuilder
+import ru.mephi.voip.data.utils.CropCircleTransformation
+import ru.mephi.voip.data.utils.parseRemoteContact
+import ru.mephi.voip.databinding.ActivityCallBinding
+
+class CallActivity : AppCompatActivity(), LifecycleOwner, View.OnClickListener,
+    OnCallConnectedListener,
+    OnRemoteAlertingListener, OnCallDisconnectedListener,
+    OnCallHeldListener, OnCallErrorListener, OnInitializeListener,
+    OnIncomingCallListener {
+
+    companion object {
+        fun create(context: Context, name: String, isIncoming: Boolean) {
+            val intent = Intent(context, CallActivity::class.java)
+            intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+            intent.putExtra(AbtoPhone.IS_INCOMING, isIncoming)
+            intent.putExtra(AbtoPhone.REMOTE_CONTACT, name)
+            context.startActivity(intent)
+        }
+    }
+
+    private lateinit var viewModel: CallViewModel
+
+    private var bIsIncoming: Boolean = false
+
+    private var mPointTime: Long = 0
+    private var mTotalTime: Long = 0
+    private val mHandler = Handler()
+    private val mUpdateTimeTask: Runnable = object : Runnable {
+        override fun run() {
+            mTotalTime += System.currentTimeMillis() - mPointTime
+            mPointTime = System.currentTimeMillis()
+            var seconds = (mTotalTime / 1000).toInt()
+            val minutes = seconds / 60
+            seconds %= 60
+            if (seconds < 10)
+                binding.callStatus.text = "$minutes:0$seconds"
+            else
+                binding.callStatus.text = "$minutes:$seconds"
+            mHandler.postDelayed(this, 1000)
+        }
+    }
+
+    private lateinit var phone: AbtoPhone
+    private lateinit var mScreenWakeLock: WakeLock
+    private lateinit var mProximityWakeLock: WakeLock
+    private var activeCallId = AbtoPhone.INVALID_CALL_ID
+
+    private var isSpeakerModeEnabled: Boolean = false
+    private var isMicMuted: Boolean = false
+
+    private lateinit var binding: ActivityCallBinding
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        initWakeLocks()
+
+        val viewModelProviderFactory =
+            CallViewModelProviderFactory(application)
+        viewModel = ViewModelProvider(this, viewModelProviderFactory)
+            .get(CallViewModel::class.java)
+
+        binding = ActivityCallBinding.inflate(layoutInflater)
+        setContentView(binding.root)
+
+        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+        window.addFlags(WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD)
+        window.addFlags(WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED)
+        window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON)
+
+        phone = (application!! as AbtoApplication).abtoPhone!!
+        //Verify mode, in which was started this activity
+        bIsIncoming = intent.getBooleanExtra(AbtoPhone.IS_INCOMING, false)
+        val startedFromService = intent.getBooleanExtra(AbtoPhone.ABTO_SERVICE_MARKER, false)
+
+        if (bIsIncoming) AbtoCallEventsReceiver.cancelIncCallNotification(this, activeCallId)
+        activeCallId = intent.getIntExtra(AbtoPhone.CALL_ID, AbtoPhone.INVALID_CALL_ID)
+
+        if (startedFromService) {
+            phone.initialize(true)
+            phone.setInitializeListener(this)
+        } else {
+            answerCallByIntent()
+        }
+
+        binding.soundBtn.setOnClickListener(this)
+        binding.micBtn.setOnClickListener(this)
+        binding.endCallBtn.setOnClickListener(this)
+        binding.acceptCallBtn.setOnClickListener(this)
+        binding.declineCallBtn.setOnClickListener(this)
+
+        //Event handlers
+        phone.setCallConnectedListener(this)
+        phone.setCallDisconnectedListener(this)
+        phone.setOnCallHeldListener(this)
+        phone.setRemoteAlertingListener(this)
+        phone.setIncomingCallListener(this)
+
+        if (mTotalTime != 0L) {
+            mHandler.removeCallbacks(mUpdateTimeTask)
+            mHandler.postDelayed(mUpdateTimeTask, 100)
+        }
+
+        Picasso.get()
+            .load(KtorClientBuilder.PHOTO_REQUEST_URL_BY_PHONE + intent.getStringExtra(AbtoPhone.REMOTE_CONTACT))
+            .resize(200, 300)
+            .transform(CropCircleTransformation())
+            .centerInside()
+            .into(binding.contactImage, object : com.squareup.picasso.Callback {
+                override fun onSuccess() {
+                    binding.imgProgressBar.visibility = View.GONE
+                }
+
+                override fun onError(e: Exception?) {
+                    binding.imgProgressBar.visibility = View.VISIBLE
+                }
+            }
+            )
+        startOutgoingCallByIntent()
+    }
+
+    override fun onBackPressed() {}
+
+    override fun onClick(v: View) {
+        when (v.id) {
+            R.id.accept_call_btn -> pickUp()
+            R.id.soundBtn -> changeSound()
+            R.id.micBtn -> micMute()
+            R.id.endCallBtn, R.id.decline_call_btn -> {
+                hangUP()
+            }
+        }
+    }
+
+    private fun hangUP() {
+        try {
+            if (bIsIncoming) phone.rejectCall(activeCallId) else phone.hangUp(activeCallId)
+        } catch (e: RemoteException) {
+        } finally {
+            finish()
+        }
+    }
+
+    private fun micMute() {
+        if (!isMicMuted) {
+            isMicMuted = true
+            Toast.makeText(this, "Микрофон выключен", Toast.LENGTH_SHORT).show()
+            binding.micBtn.background = (getDrawable(R.drawable.rounded_corners_orange))
+        } else {
+            isMicMuted = false
+            Toast.makeText(this, "Микрофон включен", Toast.LENGTH_SHORT).show()
+            binding.micBtn.background = (getDrawable(R.drawable.rounded_corners_gray))
+        }
+        phone.setMicrophoneMute(isMicMuted)
+    }
+
+    private fun changeSound() {
+        if (!isSpeakerModeEnabled) {
+            Toast.makeText(this, "Динамик включен", Toast.LENGTH_SHORT).show()
+            binding.soundBtn.background = (getDrawable(R.drawable.rounded_corners_blue))
+        } else {
+            Toast.makeText(this, "Динамик выключен", Toast.LENGTH_SHORT).show()
+            binding.soundBtn.background = (getDrawable(R.drawable.rounded_corners_gray))
+        }
+        isSpeakerModeEnabled = !isSpeakerModeEnabled
+        phone.setSpeakerphoneOn(isSpeakerModeEnabled)
+    }
+
+    override fun OnIncomingCall(callId: Int, remoteContact: String, accountId: Long) {
+        viewModel.getNameByPhone(parseRemoteContact(remoteContact).second).observe(this) { it ->
+            it.let { resource ->
+                when (resource.status) {
+                    Status.SUCCESS -> {
+                        resource.data?.let {
+                            binding.nameOfCaller.text = it.display_name
+                            viewModel.createNewCall(
+                                parseRemoteContact(remoteContact).second,
+                                it.display_name,
+                                true
+                            )
+                        }
+                    }
+                    Status.ERROR -> {
+                        viewModel.createNewCall(
+                            parseRemoteContact(remoteContact).second,
+                            parseRemoteContact(remoteContact).first,
+                            true
+                        )
+                    }
+                    Status.LOADING -> {
+                        binding.nameOfCaller.text = "..."
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onCallConnected(callId: Int, remoteContact: String) {
+        binding.callIncomingControl.visibility = View.GONE
+        binding.callControlButtons.visibility = View.VISIBLE
+
+        if (mTotalTime == 0L) {
+            mPointTime = System.currentTimeMillis()
+            mHandler.removeCallbacks(mUpdateTimeTask)
+            mHandler.postDelayed(mUpdateTimeTask, 100)
+        }
+
+        enableProximity()
+    }
+
+    override fun onRemoteAlerting(callId: Int, statusCode: Int, accId: Long) {
+        var statusText = ""
+
+        if (activeCallId == AbtoPhone.INVALID_CALL_ID) activeCallId = callId
+
+        when (statusCode) {
+            OnRemoteAlertingListener.TRYING -> statusText = "Trying"
+            OnRemoteAlertingListener.RINGING -> statusText = "Ringing"
+            OnRemoteAlertingListener.SESSION_PROGRESS -> statusText = "Session in progress"
+        }
+        binding.callStatus.text = statusText
+    }
+
+    override fun onCallDisconnected(
+        callId: Int,
+        remoteContact: String?,
+        statusCode: Int,
+        statusMessage: String?
+    ) {
+//        if (callId == activeCallId) {
+        finish()
+        mTotalTime = 0
+//        }
+    }
+
+    override fun onCallHeld(callId: Int, state: HoldState?) {
+        when (state) {
+            HoldState.LOCAL_HOLD -> binding.callStatus.text =
+                "Local Hold"
+            HoldState.REMOTE_HOLD -> binding.callStatus.text =
+                "Remote Hold"
+            HoldState.ACTIVE -> binding.callStatus.text =
+                "Active"
+        }
+    }
+
+    override fun onCallError(remoteContact: String?, statusCode: Int, message: String?) {
+        Toast.makeText(this, "onCallError: $statusCode", Toast.LENGTH_SHORT).show()
+    }
+
+    override fun onInitializeState(state: InitializeState?, message: String?) {
+        if (state == InitializeState.SUCCESS) {
+            phone.setInitializeListener(null)
+            answerCallByIntent()
+        }
+    }
+
+    private fun answerCallByIntent() {
+        if (intent.getBooleanExtra(AbtoCallEventsReceiver.KEY_PICK_UP_AUDIO, false))
+            pickUp()
+    }
+
+    private fun pickUp() {
+        try {
+            phone.answerCall(activeCallId, 200, false)
+        } catch (e: RemoteException) {
+        }
+    }
+
+    private fun startOutgoingCallByIntent() {
+        if (intent.getBooleanExtra(AbtoPhone.IS_INCOMING, true)) return
+
+        binding.callIncomingControl.visibility = View.GONE
+        binding.callControlButtons.visibility = View.VISIBLE
+
+        val sipNumber = intent.getStringExtra(AbtoPhone.REMOTE_CONTACT)!!
+
+        viewModel.createNewCall(sipNumber, "", false)
+
+        viewModel.getNameByPhone(sipNumber).observe(this, { it ->
+            it.let { resource ->
+                when (resource.status) {
+                    Status.SUCCESS -> {
+                        resource.data?.let {
+                            binding.nameOfCaller.text = it.display_name
+                        }
+                    }
+                    Status.ERROR -> {
+                        binding.nameOfCaller.text = "Имя абонента неопределено"
+                        binding.nameOfCaller.setTextColor(Color.LTGRAY)
+                    }
+                    Status.LOADING -> {
+                        binding.nameOfCaller.text = "..."
+                    }
+                }
+            }
+        })
+
+        try {
+            activeCallId =
+                phone.startCall(sipNumber, phone.currentAccountId)
+        } catch (e: RemoteException) {
+            activeCallId = -1
+            e.printStackTrace()
+        }
+
+        // Verify returned callId.
+        // End this activity when call can't be started.
+        if (activeCallId == -1) {
+            Toast.makeText(this, "Не получается позвонить контакту: $sipNumber", Toast.LENGTH_SHORT)
+                .show()
+            finish()
+        }
+    }
+
+    private fun initWakeLocks() {
+        val powerManager = getSystemService(POWER_SERVICE) as PowerManager
+        val flags = PowerManager.SCREEN_BRIGHT_WAKE_LOCK or PowerManager.ACQUIRE_CAUSES_WAKEUP
+        mScreenWakeLock = powerManager.newWakeLock(flags, "com.example.voipmephi:wakelogtag")
+        mScreenWakeLock.setReferenceCounted(false)
+        var field = 0x00000020
+        try {
+            field = PowerManager::class.java.javaClass.getField("PROXIMITY_SCREEN_OFF_WAKE_LOCK")
+                .getInt(null)
+        } catch (t: Throwable) {
+        }
+        mProximityWakeLock = powerManager.newWakeLock(field, localClassName)
+    }
+
+    private fun enableProximity() {
+        if (!mProximityWakeLock.isHeld) {
+            mProximityWakeLock.acquire(10 * 60 * 1000L /*10 minutes*/)
+        }
+    }
+}

+ 33 - 0
app/src/main/java/ru/mephi/voip/call/CallViewModel.kt

@@ -0,0 +1,33 @@
+package ru.mephi.voip.call
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.liveData
+import ru.mephi.voip.data.repository.CatalogRepository
+import ru.mephi.voip.data.database.calls.CallRecord
+import ru.mephi.voip.data.database.calls.CallRecordsDao
+import kotlinx.coroutines.Dispatchers
+import ru.mephi.voip.VoIPApplication
+import ru.mephi.voip.data.network.util.Resource
+import java.time.LocalDateTime
+
+class CallViewModel(app: Application, private val repository: CatalogRepository) : AndroidViewModel(app) {
+    private var dao: CallRecordsDao = getApplication<VoIPApplication>().callDatabase.getCallRecordsDao()
+
+    fun getNameByPhone(line: String) = liveData(Dispatchers.IO) {
+        emit(Resource.loading(data = null))
+        try {
+            emit(Resource.success(data = repository.getNameByPhone(line)))
+        } catch (exception: Exception) {
+            emit(Resource.error(data = null, message = exception.message ?: "Error Occurred!"))
+        }
+    }
+
+     fun createNewCall(sipNumber: String, sipName: String, isIncoming: Boolean){
+        addRecord(CallRecord(null, sipNumber, sipName, isIncoming, LocalDateTime.now()))
+    }
+
+    private fun addRecord(callRecord: CallRecord){
+        dao.insertAll(callRecord)
+    }
+}

+ 14 - 0
app/src/main/java/ru/mephi/voip/call/CallViewModelProviderFactory.kt

@@ -0,0 +1,14 @@
+package ru.mephi.voip.call
+
+import android.app.Application
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import ru.mephi.voip.data.repository.CatalogRepository
+
+class CallViewModelProviderFactory(
+    val app: Application
+) : ViewModelProvider.Factory {
+    override fun <T : ViewModel> create(modelClass: Class<T>): T {
+        return CallViewModel(app, CatalogRepository()) as T
+    }
+}

+ 65 - 0
app/src/main/java/ru/mephi/voip/call/SoundManager.kt

@@ -0,0 +1,65 @@
+package ru.mephi.voip.call
+
+import android.content.Context
+import android.media.AudioManager
+import android.media.Ringtone
+import android.media.RingtoneManager
+import android.media.ToneGenerator
+import android.net.Uri
+import android.os.Vibrator
+import android.provider.Settings
+
+class SoundManager private constructor() {
+    private lateinit var context: Context
+    private var ringtone: Ringtone? = null
+    private var tone: ToneGenerator? = null
+
+    companion object {
+        private val instance: SoundManager by lazy { SoundManager() }
+
+        fun getInstance(context: Context): SoundManager {
+            instance.context = context
+            return instance
+        }
+    }
+
+    fun startTone(code: Int) {
+        val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+        val maxVolume = am.getStreamMaxVolume(AudioManager.STREAM_VOICE_CALL)
+
+        if (tone == null) {
+            tone = ToneGenerator(AudioManager.STREAM_VOICE_CALL, 100)
+        }
+
+        am.setStreamVolume(AudioManager.STREAM_VOICE_CALL, maxVolume, 0)
+        am.isSpeakerphoneOn = false
+        tone!!.startTone(code)
+    }
+
+    @Suppress("DEPRECATION")
+    fun startRinging() {
+        val am = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
+        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+
+        vibrator.vibrate(longArrayOf(0, 1000, 1000), 1)
+
+        if (am.getStreamVolume(AudioManager.STREAM_RING) > 0) {
+            val ringtoneUri = Settings.System.DEFAULT_RINGTONE_URI.toString()
+            ringtone = RingtoneManager.getRingtone(context, Uri.parse(ringtoneUri))
+            ringtone!!.play()
+        }
+    }
+
+    fun stopTone() {
+        tone?.stopTone()
+        tone?.release()
+        tone = null
+    }
+
+    fun stopRinging() {
+        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
+        vibrator.cancel()
+        ringtone?.stop()
+        ringtone = null
+    }
+}

+ 31 - 0
app/src/main/java/ru/mephi/voip/call/abto/AbtoApp.kt

@@ -0,0 +1,31 @@
+package ru.mephi.voip.call.abto
+
+import android.content.IntentFilter
+import org.abtollc.sdk.AbtoApplication
+import org.abtollc.sdk.AbtoPhone
+
+open class AbtoApp : AbtoApplication() {
+    private val callEventsReceiver = CallEventsReceiver()
+    private var appInBackgroundHandler: AppInBackgroundHandler? = null
+    override fun onCreate() {
+        super.onCreate()
+        app = this
+        registerReceiver(callEventsReceiver, IntentFilter(AbtoPhone.ACTION_ABTO_CALL_EVENT))
+        appInBackgroundHandler = AppInBackgroundHandler()
+        registerActivityLifecycleCallbacks(appInBackgroundHandler)
+    }
+
+    override fun onTerminate() {
+        super.onTerminate()
+        unregisterReceiver(callEventsReceiver)
+        unregisterActivityLifecycleCallbacks(appInBackgroundHandler)
+    }
+
+    val isAppInBackground: Boolean
+        get() = appInBackgroundHandler!!.isAppInBackground
+
+    companion object {
+        var app: AbtoApp? = null
+            private set
+    }
+}

+ 7 - 0
app/src/main/java/ru/mephi/voip/call/abto/AccountStatus.kt

@@ -0,0 +1,7 @@
+package ru.mephi.voip.call.abto
+
+enum class AccountStatus {
+    NO_CONNECTION,
+    UNREGISTERED,
+    REGISTERED,
+}

+ 24 - 0
app/src/main/java/ru/mephi/voip/call/abto/AppInBackgroundHandler.kt

@@ -0,0 +1,24 @@
+package ru.mephi.voip.call.abto
+
+import android.app.Activity
+import android.app.Application.ActivityLifecycleCallbacks
+import android.os.Bundle
+
+class AppInBackgroundHandler : ActivityLifecycleCallbacks {
+    private var activeActivities = 0
+    override fun onActivityCreated(activity: Activity, bundle: Bundle?) {}
+    override fun onActivityStarted(activity: Activity) {}
+    override fun onActivityResumed(activity: Activity) {
+        activeActivities++
+    }
+
+    override fun onActivityPaused(activity: Activity) {
+        activeActivities--
+    }
+
+    override fun onActivityStopped(activity: Activity) {}
+    override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) {}
+    override fun onActivityDestroyed(activity: Activity) {}
+    val isAppInBackground: Boolean
+        get() = activeActivities == 0
+}

+ 129 - 0
app/src/main/java/ru/mephi/voip/call/abto/CallEventsReceiver.kt

@@ -0,0 +1,129 @@
+package ru.mephi.voip.call.abto
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.os.RemoteException
+import androidx.core.app.NotificationCompat
+import ru.mephi.voip.call.CallActivity
+import org.abtollc.sdk.AbtoPhone
+import ru.mephi.voip.R
+import timber.log.Timber
+
+class CallEventsReceiver : BroadcastReceiver() {
+    override fun onReceive(context: Context, intent: Intent) {
+        val bundle = intent.extras ?: return
+        when {
+            bundle.getBoolean(AbtoPhone.IS_INCOMING, false) -> {
+                // Incoming call
+                Timber.d("INCOMING CALL")
+                buildIncomingCallNotification(context, bundle)
+            }
+            // Отклонение звонка
+            bundle.getBoolean(KEY_REJECT_CALL, false) -> {
+                // Reject call
+                val callId = bundle.getInt(AbtoPhone.CALL_ID)
+                cancelIncCallNotification(context, callId)
+                try {
+                    AbtoApp.app?.abtoPhone?.rejectCall(callId)
+                } catch (e: RemoteException) {
+                    e.printStackTrace()
+                }
+            }
+            bundle.getInt(AbtoPhone.CODE) == -1 -> {
+                // Cancel call
+                val callId = bundle.getInt(AbtoPhone.CALL_ID)
+                cancelIncCallNotification(context, callId)
+            }
+        }
+    }
+
+    private fun buildIncomingCallNotification(context: Context, bundle: Bundle) {
+        val intent = Intent(context, CallActivity::class.java)
+        intent.putExtras(bundle)
+
+        if (!AbtoApp.app?.isAppInBackground!!) {
+            //App is foreground - start activity
+            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            context.startActivity(intent)
+            return
+        }
+
+        val title = "Входящий звонок"
+        val remoteContact = bundle.getString(AbtoPhone.REMOTE_CONTACT)
+        val callId = bundle.getInt(AbtoPhone.CALL_ID)
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && channelCall == null) {
+            channelCall = NotificationChannel(CHANEL_CALL_ID, context.getString(R.string.app_name) + "Call", NotificationManager.IMPORTANCE_HIGH)
+            channelCall!!.description = context.getString(R.string.app_name)
+            val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+            notificationManager.createNotificationChannel(channelCall!!)
+        }
+
+        val notificationPendingIntent = PendingIntent.getActivity(
+            context, 1, intent,
+            PendingIntent.FLAG_UPDATE_CURRENT
+        )
+        // Intent for pickup audio call
+        val pickUpAudioIntent = Intent(context, CallActivity::class.java)
+        pickUpAudioIntent.putExtras(bundle)
+        pickUpAudioIntent.putExtra(KEY_PICK_UP_AUDIO, true)
+        val pickUpAudioPendingIntent = PendingIntent.getActivity(
+            context, 2,
+            pickUpAudioIntent, PendingIntent.FLAG_UPDATE_CURRENT
+        )
+        // Intent for reject call
+        val rejectCallIntent = Intent()
+        rejectCallIntent.setPackage(context.packageName)
+        rejectCallIntent.action = AbtoPhone.ACTION_ABTO_CALL_EVENT
+        rejectCallIntent.putExtra(AbtoPhone.CALL_ID, callId)
+        rejectCallIntent.putExtra(KEY_REJECT_CALL, true)
+        val pendingRejectCall = PendingIntent.getBroadcast(
+            context, 4, rejectCallIntent,
+            PendingIntent.FLAG_CANCEL_CURRENT
+        )
+
+        // Style for popup notification
+        val bigText = NotificationCompat.BigTextStyle()
+        bigText.bigText(remoteContact)
+        bigText.setBigContentTitle(title)
+
+        // Create notification
+        val builder = NotificationCompat.Builder(context, CHANEL_CALL_ID)
+        builder.setSmallIcon(R.drawable.ic_baseline_dialer_sip_24)
+//            .setColor(-0xff0100)
+            .setContentTitle(title)
+            .setContentIntent(notificationPendingIntent)
+            .setContentText(remoteContact)
+            .setDefaults(Notification.DEFAULT_ALL)
+            .setStyle(bigText)
+            .setPriority(Notification.PRIORITY_MAX)
+            .setCategory(NotificationCompat.CATEGORY_CALL)
+            .setSmallIcon(R.drawable.logo_voip)
+            .addAction(R.drawable.icon_call_end, "Отклонить", pendingRejectCall)
+            .addAction(R.drawable.icon_volume, "Принять", pickUpAudioPendingIntent)
+            .setFullScreenIntent(notificationPendingIntent, true)
+        val mNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+        val notification = builder.build()
+        mNotificationManager.notify(NOTIFICATION_INCOMING_CALL_ID + callId, notification)
+    }
+
+    companion object {
+        const val CHANEL_CALL_ID = "abto_phone_call"
+        private var channelCall: NotificationChannel? = null
+        const val KEY_PICK_UP_AUDIO = "KEY_PICK_UP_AUDIO"
+        const val KEY_REJECT_CALL = "KEY_REJECT_CALL"
+        private const val NOTIFICATION_INCOMING_CALL_ID = 1000
+
+        fun cancelIncCallNotification(context: Context, callId: Int) {
+            val mNotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+            mNotificationManager.cancel(NOTIFICATION_INCOMING_CALL_ID + callId)
+        }
+    }
+}

+ 20 - 0
app/src/main/java/ru/mephi/voip/data/database/DatabaseViewModel.kt

@@ -0,0 +1,20 @@
+package ru.mephi.voip.data.database
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+
+abstract class DatabaseViewModel<T>(app: Application) :
+    AndroidViewModel(app) {
+
+    abstract var dao: RecordsDao<T>
+
+    open fun getRecords() = dao.getAll()
+
+    open fun addRecord(record: T) {
+        dao.insertAll()
+    }
+
+    open fun deleteAllRecords() {
+        dao.deleteAll()
+    }
+}

+ 11 - 0
app/src/main/java/ru/mephi/voip/data/database/RecordsDao.kt

@@ -0,0 +1,11 @@
+package ru.mephi.voip.data.database
+
+interface RecordsDao<T> {
+    fun getAll(): List<T>
+
+    fun insertAll(vararg record: T)
+
+    fun deleteRecords(vararg record: T)
+
+    fun deleteAll()
+}

+ 11 - 0
app/src/main/java/ru/mephi/voip/data/database/calls/CallDatabase.kt

@@ -0,0 +1,11 @@
+package ru.mephi.voip.data.database.calls
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+
+@Database(entities = [CallRecord::class], version = 1)
+@TypeConverters(LocalDateTimeConverter::class)
+abstract class CallDatabase : RoomDatabase() {
+    abstract fun getCallRecordsDao(): CallRecordsDao
+}

+ 14 - 0
app/src/main/java/ru/mephi/voip/data/database/calls/CallRecord.kt

@@ -0,0 +1,14 @@
+package ru.mephi.voip.data.database.calls
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import java.time.LocalDateTime
+
+@Entity(tableName = "call_history")
+class CallRecord(
+    @PrimaryKey val id: Int?,
+    val sipNumber: String,
+    val sipName: String?,
+    val isIncoming: Boolean,
+    val time: LocalDateTime
+)

+ 22 - 0
app/src/main/java/ru/mephi/voip/data/database/calls/CallRecordsDao.kt

@@ -0,0 +1,22 @@
+package ru.mephi.voip.data.database.calls
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import ru.mephi.voip.data.database.RecordsDao
+
+@Dao
+interface CallRecordsDao : RecordsDao<CallRecord> {
+    @Query("SELECT * FROM call_history ORDER by time DESC")
+    override fun getAll(): List<CallRecord>
+
+    @Insert
+    override fun insertAll(vararg record: CallRecord)
+
+    @Delete
+    override fun deleteRecords(vararg record: CallRecord)
+
+    @Query("DELETE FROM call_history")
+    override fun deleteAll()
+}

+ 27 - 0
app/src/main/java/ru/mephi/voip/data/database/calls/LocalDateTimeConverter.kt

@@ -0,0 +1,27 @@
+package ru.mephi.voip.data.database.calls
+
+import androidx.room.TypeConverter
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+
+
+object LocalDateTimeConverter {
+    @TypeConverter
+    fun toDate(dateString: String?): LocalDateTime? {
+        return if (dateString == null) {
+            null
+        } else {
+            LocalDateTime.parse(dateString)
+        }
+    }
+
+    @TypeConverter
+    fun toDateString(date: LocalDateTime?): String? {
+        return date?.toString()
+    }
+
+    fun getDateAndTime(localDateTime: LocalDateTime): String{
+        val dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")
+        return dtf.format(localDateTime)
+    }
+}

+ 9 - 0
app/src/main/java/ru/mephi/voip/data/database/search/SearchDatabase.kt

@@ -0,0 +1,9 @@
+package ru.mephi.voip.data.database.search
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(entities = [SearchRecord::class], version = 1)
+abstract class SearchDatabase : RoomDatabase() {
+    abstract fun getSearchRecordsDao(): SearchRecordsDao
+}

+ 12 - 0
app/src/main/java/ru/mephi/voip/data/database/search/SearchRecord.kt

@@ -0,0 +1,12 @@
+package ru.mephi.voip.data.database.search
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import ru.mephi.voip.data.utils.SearchType
+
+@Entity(tableName = "search_history")
+class SearchRecord(
+    @PrimaryKey val id: Int?,
+    val name: String,
+    val type: SearchType
+)

+ 25 - 0
app/src/main/java/ru/mephi/voip/data/database/search/SearchRecordsDao.kt

@@ -0,0 +1,25 @@
+package ru.mephi.voip.data.database.search
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Query
+import ru.mephi.voip.data.database.RecordsDao
+
+@Dao
+interface SearchRecordsDao : RecordsDao<SearchRecord> {
+    @Query("SELECT * FROM search_history")
+    override fun getAll(): List<SearchRecord>
+
+    @Insert
+    override fun insertAll(vararg record: SearchRecord)
+
+    @Delete
+    override fun deleteRecords(vararg record: SearchRecord)
+
+    @Query("DELETE FROM search_history")
+    override fun deleteAll()
+
+    @Query("SELECT EXISTS(SELECT * FROM search_history WHERE name = :name)")
+    fun isExists(name: String): Boolean
+}

+ 6 - 0
app/src/main/java/ru/mephi/voip/data/model/Account.kt

@@ -0,0 +1,6 @@
+package ru.mephi.voip.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Account(val login: String, val password: String, var isActive: Boolean)

+ 19 - 0
app/src/main/java/ru/mephi/voip/data/model/Appointment.kt

@@ -0,0 +1,19 @@
+package ru.mephi.voip.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class Appointment(
+    val appointment_id: String,
+    val subscriber_id: String,
+    val EmpGUID: String? = null,
+    val appointment: String? = null,
+    val lastname: String,
+    val firstname: String,
+    val patronymic: String? = null,
+    val fullname: String,
+    val fio: String,
+    val line: String? = null,
+    val email: String? = null,
+    val room: String? = null
+)

+ 9 - 0
app/src/main/java/ru/mephi/voip/data/model/NameItem.kt

@@ -0,0 +1,9 @@
+package ru.mephi.voip.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+class NameItem(
+    val display_name: String,
+    val display_name_latin: String
+)

+ 15 - 0
app/src/main/java/ru/mephi/voip/data/model/UnitM.kt

@@ -0,0 +1,15 @@
+package ru.mephi.voip.data.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class UnitM(
+    val code_str: String,
+    val name: String,
+    val fullname: String,
+    var shortname: String,
+    var parent_code: String? = null,
+    var parent_name: String? = null,
+    val children: List<UnitM>? = null,
+    val appointments: List<Appointment>? = null
+)

+ 62 - 0
app/src/main/java/ru/mephi/voip/data/network/ConnectionStateMonitor.kt

@@ -0,0 +1,62 @@
+package ru.mephi.voip.data.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import androidx.annotation.RequiresApi
+
+@RequiresApi(Build.VERSION_CODES.LOLLIPOP)
+class ConnectionStateMonitor(
+    private val context: Context,
+    private val onNetworkAvailableCallbacks: OnNetworkAvailableCallbacks
+) : ConnectivityManager.NetworkCallback() {
+
+    private val connectivityManager: ConnectivityManager by lazy {
+        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    }
+
+    private val networkRequest = NetworkRequest.Builder()
+        .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
+        .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build()!!
+
+    /**
+     * @return`true` when device is connected to network else `false`
+     */
+    fun hasNetworkConnection() = (connectivityManager.activeNetworkInfo != null)
+
+    fun enable() {
+        connectivityManager.registerNetworkCallback(networkRequest, this)
+    }
+
+    fun disable() {
+        connectivityManager.unregisterNetworkCallback(this)
+    }
+
+    override fun onAvailable(network: Network) {
+        super.onAvailable(network)
+        onNetworkAvailableCallbacks.onPositive()
+    }
+
+    override fun onLost(network: Network) {
+        super.onLost(network)
+        onNetworkAvailableCallbacks.onNegative()
+    }
+
+    /**
+     * Callbacks to handle the network status changes
+     */
+    interface OnNetworkAvailableCallbacks {
+        /**
+         * Callback for when network is available
+         */
+        fun onPositive()
+
+        /**
+         * Callback for when network is lost/disconnected
+         */
+        fun onNegative()
+    }
+}

+ 45 - 0
app/src/main/java/ru/mephi/voip/data/network/CustomSnackBar.kt

@@ -0,0 +1,45 @@
+package ru.mephi.voip.data.network
+
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import com.google.android.material.snackbar.BaseTransientBottomBar
+import ru.mephi.voip.R
+
+class CustomSnackBar private constructor(
+    parent: ViewGroup, content: View,
+    callback: com.google.android.material.snackbar.ContentViewCallback
+) : BaseTransientBottomBar<CustomSnackBar>(parent, content, callback) {
+
+    fun setText(text: CharSequence): CustomSnackBar {
+        val textView = getView().findViewById<View>(R.id.snack_bar_text) as TextView
+        textView.text = text
+        return this
+    }
+
+    private class CustomContentViewCallback(private val content: View) :
+        com.google.android.material.snackbar.ContentViewCallback {
+
+        override fun animateContentIn(delay: Int, duration: Int) {
+        }
+
+        override fun animateContentOut(delay: Int, duration: Int) {
+//            ObjectAnimator.ofFloat(content, View.SCALE_Y, 1f, 0f).setDuration(1000).start()
+        }
+    }
+
+    companion object {
+        fun make(parent: ViewGroup, duration: Int): CustomSnackBar {
+            val inflater = LayoutInflater.from(parent.context)
+            val content = inflater.inflate(R.layout.custom_snackbar, parent, false)
+            val viewCallback = CustomContentViewCallback(content)
+
+            return CustomSnackBar(parent, content, viewCallback).run {
+                getView().setPadding(0, 0, 0, 0)
+                this.duration = duration
+                this
+            }
+        }
+    }
+}

+ 64 - 0
app/src/main/java/ru/mephi/voip/data/network/NetworkSensingBaseActivity.kt

@@ -0,0 +1,64 @@
+package ru.mephi.voip.data.network
+
+import android.annotation.SuppressLint
+import android.view.ViewGroup
+import androidx.appcompat.app.AppCompatActivity
+import com.google.android.material.snackbar.Snackbar
+import ru.mephi.voip.data.network.ConnectionStateMonitor
+import ru.mephi.voip.data.network.CustomSnackBar
+
+@SuppressLint("Registered")
+open class NetworkSensingBaseActivity : AppCompatActivity(),
+    ConnectionStateMonitor.OnNetworkAvailableCallbacks
+//    OnNetworkEventListener
+{
+
+    private var snackBar: CustomSnackBar? = null
+    private var connectionStateMonitor: ConnectionStateMonitor? = null
+    private var viewGroup: ViewGroup? = null
+
+    override fun onResume() {
+        super.onResume()
+        if (viewGroup == null) viewGroup = findViewById(android.R.id.content)
+        if (snackBar == null)
+            snackBar = CustomSnackBar.make(viewGroup!!, Snackbar.LENGTH_INDEFINITE).setText("No internet connection.")
+
+        if (connectionStateMonitor == null)
+            connectionStateMonitor = ConnectionStateMonitor(this, this)
+        connectionStateMonitor?.enable()        //Register
+
+        // Recheck network status manually whenever activity resumes
+        if (connectionStateMonitor?.hasNetworkConnection() == false) onNegative()
+        else onPositive()
+    }
+
+    override fun onPause() {
+        snackBar?.dismiss()
+        snackBar = null
+        connectionStateMonitor?.disable()
+        connectionStateMonitor = null
+        super.onPause()
+    }
+
+    override fun onPositive() {
+        runOnUiThread {
+            snackBar?.dismiss()
+        }
+    }
+
+    override fun onNegative() {
+        runOnUiThread {
+            snackBar?.setText("Интернет-соединение отсутствует")?.show()
+        }
+    }
+
+//    override fun onNetworkStateChanged(connected: Boolean, networkType: String) {
+//        runOnUiThread {
+//            if (connected)
+//                snackBar?.dismiss()
+//            else
+//                snackBar?.setText("Интернет-соединение отсутствует")?.show()
+//        }
+//    }
+
+}

+ 59 - 0
app/src/main/java/ru/mephi/voip/data/network/NetworkSensingBaseFragment.kt

@@ -0,0 +1,59 @@
+package ru.mephi.voip.data.network
+
+import android.annotation.SuppressLint
+import android.util.Log
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import com.google.android.material.snackbar.Snackbar
+import ru.mephi.voip.data.network.ConnectionStateMonitor
+import ru.mephi.voip.data.network.CustomSnackBar
+
+@SuppressLint("Registered")
+open class NetworkSensingBaseFragment : Fragment(),
+    ConnectionStateMonitor.OnNetworkAvailableCallbacks {
+
+    private var snackBar: CustomSnackBar? = null
+    private var connectionStateMonitor: ConnectionStateMonitor? = null
+    private var viewGroup: ViewGroup? = null
+
+    override fun onResume() {
+        super.onResume()
+        if (viewGroup == null) viewGroup = requireActivity().findViewById(android.R.id.content)
+
+        if (snackBar == null)
+            snackBar = CustomSnackBar.make(viewGroup!!, Snackbar.LENGTH_INDEFINITE)
+
+        if (connectionStateMonitor == null)
+            connectionStateMonitor = ConnectionStateMonitor(requireActivity(), this)
+
+        connectionStateMonitor?.enable()  //Register
+
+        // Recheck network status manually whenever activity resumes
+        if (connectionStateMonitor?.hasNetworkConnection() == false) onNegative()
+        else onPositive()
+    }
+
+    override fun onPause() {
+        snackBar = null
+        connectionStateMonitor?.disable() //Unregister
+        connectionStateMonitor = null
+        super.onPause()
+    }
+
+    override fun onPositive() {
+        Log.d("Internet", "Сonnected")
+
+        requireActivity().runOnUiThread {
+            snackBar?.dismiss()
+        }
+    }
+
+    override fun onNegative() {
+        Log.d("Internet", "Disconnected")
+
+        requireActivity().runOnUiThread {
+            snackBar?.setText("Интернет-соединение отсутствует")?.show()
+        }
+    }
+
+}

+ 12 - 0
app/src/main/java/ru/mephi/voip/data/network/api/ApiHelper.kt

@@ -0,0 +1,12 @@
+package ru.mephi.voip.data.network.api
+
+import org.koin.java.KoinJavaComponent.inject
+
+// Может принимать как вариант с Retrofit (Android-only), так и Ktor (KMM)
+class ApiHelper {
+    private val apiService: KtorApiService by inject(KtorApiService::class.java)
+    suspend fun getNameByPhone(num: String) = apiService.getNameByPhone(num)
+    suspend fun getUnitsByName(name: String) = apiService.getUnitsByName(name)
+    suspend fun getUsersByName(name: String) = apiService.getUsersByName(name)
+    suspend fun getUnitByCodeStr(codeStr: String) = apiService.getUnitByCodeStr(codeStr)
+}

+ 19 - 0
app/src/main/java/ru/mephi/voip/data/network/api/BaseApiService.kt

@@ -0,0 +1,19 @@
+package ru.mephi.voip.data.network.api
+
+import ru.mephi.voip.data.model.NameItem
+import ru.mephi.voip.data.model.UnitM
+
+interface BaseApiService {
+    // При щелчке по юниту из списка и стартовой загрузке
+    // get_units_mobile.json?api_key=ВАШКЛЮЧ&filter_code_str=01 536 00
+    suspend fun getUnitByCodeStr(codeStr: String): List<UnitM>
+
+    // get_subscribers_mobile.json?api_key=ВАШКЛЮЧ&filter_lastname=LIKE|%Трут%
+    suspend fun getUsersByName(filterLike: String): UnitM
+
+    // get_units_mobile.json?api_key=ВАШКЛЮЧ&filter_fullname=LIKE|%информ%
+    suspend fun getUnitsByName(filterLike: String): List<UnitM>
+
+    // get_displayname.json?api_key=КЛЮЧ&line=9295
+    suspend fun getNameByPhone(phone: String): NameItem
+}

+ 44 - 0
app/src/main/java/ru/mephi/voip/data/network/api/KtorApiService.kt

@@ -0,0 +1,44 @@
+package ru.mephi.voip.data.network.api
+
+import io.ktor.client.request.*
+import ru.mephi.voip.data.model.NameItem
+import ru.mephi.voip.data.model.UnitM
+
+class KtorApiService : BaseApiService {
+    private var httpClient = KtorClientBuilder.createHttpClient()
+    override suspend fun getUnitByCodeStr(codeStr: String): List<UnitM> {
+        return httpClient.get {
+            url {
+                path("get_units_mobile.json")
+                parameter("filter_code_str", codeStr)
+            }
+        }
+    }
+
+    override suspend fun getUsersByName(filterLike: String): UnitM {
+        return httpClient.get {
+            url {
+                path("get_subscribers_mobile.json")
+                parameter("filter_lastname", filterLike)
+            }
+        }
+    }
+
+    override suspend fun getUnitsByName(filterLike: String): List<UnitM> {
+        return httpClient.get {
+            url {
+                path("get_units_mobile.json")
+                parameter("filter_fullname", filterLike)
+            }
+        }
+    }
+
+    override suspend fun getNameByPhone(phone: String): NameItem {
+        return httpClient.get<List<NameItem>> {
+            url {
+                path("get_displayname.json")
+                parameter("line", phone)
+            }
+        }[0]
+    }
+}

+ 54 - 0
app/src/main/java/ru/mephi/voip/data/network/api/KtorClientBuilder.kt

@@ -0,0 +1,54 @@
+package ru.mephi.voip.data.network.api
+
+import io.ktor.client.*
+import io.ktor.client.engine.okhttp.*
+import io.ktor.client.features.*
+import io.ktor.client.features.json.*
+import io.ktor.client.features.json.serializer.*
+import io.ktor.client.request.*
+import io.ktor.http.*
+import io.ktor.http.ContentType.Application.Json
+import kotlinx.serialization.json.Json
+
+private val json = Json {
+    ignoreUnknownKeys = true
+    isLenient = true
+    encodeDefaults = false
+}
+
+object KtorClientBuilder {
+    private const val BASE_URL = "https://sd.mephi.ru/api/6/"
+    private const val API_KEY = "theiTh0Wohtho\$uquie)kooc"
+
+    const val PHOTO_REQUEST_URL_BY_PHONE = // берет фотку из voip
+        BASE_URL + "get_photo_mobile.jpg?api_key=${API_KEY}&phone="
+    const val PHOTO_REQUEST_URL_BY_GUID = // берет фотку из cps
+        BASE_URL + "get_photo_mobile.jpg?api_key=${API_KEY}&EmpGUID="
+
+//    val API_SERVICE: KtorApiService = KtorApiService()
+
+    fun createHttpClient(): HttpClient {
+        return HttpClient(OkHttp) {
+            install(JsonFeature) {
+                serializer = KotlinxSerializer(json)
+            }
+            defaultRequest {
+                url.takeFrom(URLBuilder().takeFrom(Url(BASE_URL)).apply {
+                    encodedPath += url.encodedPath
+                })
+            }
+            install(HttpTimeout) {
+                requestTimeoutMillis = 15000L
+                connectTimeoutMillis = 15000L
+                socketTimeoutMillis = 15000L
+            }
+            // Apply to All Requests
+            defaultRequest {
+                parameter("api_key", API_KEY)
+                // Content Type
+                if (this.method != HttpMethod.Get) contentType(Json)
+                accept(Json)
+            }
+        }
+    }
+}

+ 12 - 0
app/src/main/java/ru/mephi/voip/data/network/util/Resource.kt

@@ -0,0 +1,12 @@
+package ru.mephi.voip.data.network.util
+
+data class Resource<out T>(val status: Status, val data: T?, val message: String?) {
+    companion object {
+        fun <T> success(data: T): Resource<T> = Resource(status = Status.SUCCESS, data = data, message = null)
+
+        fun <T> error(data: T?, message: String): Resource<T> =
+            Resource(status = Status.ERROR, data = data, message = message)
+
+        fun <T> loading(data: T?): Resource<T> = Resource(status = Status.LOADING, data = data, message = null)
+    }
+}

+ 7 - 0
app/src/main/java/ru/mephi/voip/data/network/util/Status.kt

@@ -0,0 +1,7 @@
+package ru.mephi.voip.data.network.util
+
+enum class Status {
+    SUCCESS,
+    ERROR,
+    LOADING
+}

+ 42 - 0
app/src/main/java/ru/mephi/voip/data/repository/CatalogCacheRepository.kt

@@ -0,0 +1,42 @@
+package ru.mephi.voip.data.repository
+
+import ru.mephi.voip.data.model.NameItem
+import ru.mephi.voip.data.model.UnitM
+import org.kodein.db.DB
+import org.kodein.db.deleteAll
+import org.kodein.db.find
+import org.kodein.db.impl.open
+import org.kodein.db.orm.kotlinx.KotlinxSerializer
+import org.kodein.db.useModels
+import ru.mephi.voip.getApplicationFilesDirectoryPath
+import ru.mephi.voip.ui.catalog.AppointmentKodein
+import ru.mephi.voip.ui.catalog.UnitMKodeIn
+import ru.mephi.voip.ui.catalog.fromKodein
+
+object CatalogCacheRepository {
+    private val db = DB.open(getApplicationFilesDirectoryPath(),
+        KotlinxSerializer {
+            +UnitMKodeIn.serializer()
+            +AppointmentKodein.serializer()
+            +NameItem.serializer()
+        })
+
+    fun checkByCodeStr(code_str: String) =
+        db.find<UnitMKodeIn>().byId(code_str).isValid()
+
+    fun add(unitM: UnitMKodeIn) {
+        db.put(db.keyFrom(unitM), unitM)
+    }
+
+    fun deleteAll() {
+        db.deleteAll(db.find<UnitMKodeIn>().all())
+    }
+
+    fun getAll(): List<UnitM> {
+        return db.find<UnitMKodeIn>().all().useModels { it.toList() }.map { it.fromKodein }
+    }
+
+    fun getUnitsByCodeStr(code_str: String): List<UnitM> {
+        return listOf(db.find<UnitMKodeIn>().byId(code_str).model().fromKodein)
+    }
+}

+ 31 - 0
app/src/main/java/ru/mephi/voip/data/repository/CatalogRepository.kt

@@ -0,0 +1,31 @@
+package ru.mephi.voip.data.repository
+
+import ru.mephi.voip.data.model.UnitM
+import org.koin.java.KoinJavaComponent.inject
+import ru.mephi.voip.appContext
+import ru.mephi.voip.data.network.api.ApiHelper
+import ru.mephi.voip.data.utils.isOnline
+import ru.mephi.voip.ui.catalog.toKodeIn
+
+class CatalogRepository {
+    private val apiHelper: ApiHelper by inject(ApiHelper::class.java)
+    suspend fun getNameByPhone(num: String) = apiHelper.getNameByPhone(num)
+    suspend fun getUnitsByName(name: String): UnitM {
+        val children = mutableListOf<UnitM>()
+        apiHelper.getUnitsByName(name).forEach {
+            children.add(it)
+        }
+        return UnitM("", name, name, name, "", "", children, null)
+    }
+
+    suspend fun getUsersByName(name: String) = apiHelper.getUsersByName(name)
+    suspend fun getUnitsByCodeStr(codeStr: String): List<UnitM> {
+        val units = if (isOnline(appContext)) {
+            apiHelper.getUnitByCodeStr(codeStr).onEach { unit ->
+                CatalogCacheRepository.add(unit.toKodeIn)
+            }
+        } else
+            CatalogCacheRepository.getUnitsByCodeStr(codeStr)
+        return units
+    }
+}

+ 17 - 0
app/src/main/java/ru/mephi/voip/data/utils/Animation.kt

@@ -0,0 +1,17 @@
+package ru.mephi.voip.data.utils
+
+import android.view.View
+
+class Animation {
+    companion object {
+        fun toggleArrow(view: View, isExpanded: Boolean): Boolean {
+            return if (isExpanded) {
+                view.animate().setDuration(200).rotation(180F)
+                true
+            } else {
+                view.animate().setDuration(200).rotation(0F)
+                false
+            }
+        }
+    }
+}

+ 39 - 0
app/src/main/java/ru/mephi/voip/data/utils/RoundedTransformation.kt

@@ -0,0 +1,39 @@
+package  ru.mephi.voip.data.utils
+
+import android.graphics.*
+import com.squareup.picasso.Transformation
+
+class CropCircleTransformation : Transformation {
+    override fun transform(source: Bitmap): Bitmap {
+        val size = source.width.coerceAtMost(source.height)
+        val width = (source.width - size) / 2
+        val height = (source.height - size) / 2
+        val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
+        val canvas = Canvas(bitmap)
+        val paint = Paint()
+        val shader = BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
+        if (width != 0 || height != 0) {
+            val matrix = Matrix()
+            matrix.setTranslate(-width.toFloat(), -height.toFloat())
+            shader.setLocalMatrix(matrix)
+        }
+        paint.shader = shader
+        paint.color = Color.rgb(0, 187, 238)
+        paint.strokeWidth = 2f
+        paint.isAntiAlias = true
+        val r = size / 2f
+        canvas.drawCircle(r, r, r, paint)
+        val paintBorder = Paint()
+        paintBorder.color = Color.rgb(0, 187, 238)
+        paintBorder.style = Paint.Style.STROKE
+        paintBorder.isAntiAlias = true
+        paintBorder.strokeWidth = 2f
+        canvas.drawCircle(r, r, r - 1, paintBorder)
+        source.recycle()
+        return bitmap
+    }
+
+    override fun key(): String {
+        return "CircleTransformation()"
+    }
+}

+ 5 - 0
app/src/main/java/ru/mephi/voip/data/utils/SearchType.kt

@@ -0,0 +1,5 @@
+package ru.mephi.voip.data.utils
+
+enum class SearchType {
+    USERS, UNITS
+}

+ 205 - 0
app/src/main/java/ru/mephi/voip/data/utils/Utils.kt

@@ -0,0 +1,205 @@
+package ru.mephi.voip.data.utils
+
+import android.animation.AnimatorSet
+import android.animation.ObjectAnimator
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import android.net.Uri
+import android.view.View
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.TranslateAnimation
+import android.view.inputmethod.InputMethodManager
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import com.squareup.moshi.JsonAdapter
+import com.squareup.moshi.Moshi
+import com.squareup.moshi.Types
+import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
+import ru.mephi.voip.data.model.Account
+import timber.log.Timber
+import java.lang.reflect.ParameterizedType
+import java.util.*
+
+const val DURATION: Long = 50
+
+const val SIP_DOMAIN = "pbx.mephi.ru"
+const val SIP_ACCOUNTS = "accounts"
+const val MEPHI_NUMBER = "+7 (495) 788 56 99"
+
+fun parseRemoteContact(remoteContact: String): Pair<String, String> { // return Name and Number
+    val name = remoteContact.substringAfter("\"").substringBefore("\"")
+    val sipNumber = remoteContact.substringAfter(":").substringBefore("@")
+    return Pair(name, sipNumber)
+}
+
+fun Activity.hideKeyboard() {
+    val view = currentFocus
+    if (view != null) {
+        val inputMethodManager =
+            getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager
+
+        inputMethodManager.hideSoftInputFromWindow(
+            view.windowToken,
+            InputMethodManager.HIDE_NOT_ALWAYS
+        )
+    }
+}
+
+fun <T> Stack<T>.popFromStackTill(el: T) {
+    while (this.peek() != el)
+        this.pop()
+}
+
+fun Context.toast(message: CharSequence) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
+fun Fragment.toast(message: CharSequence?) =
+    Toast.makeText(requireContext(), message, Toast.LENGTH_SHORT).show()
+
+val moshi: Moshi = Moshi.Builder()
+    .add(KotlinJsonAdapterFactory()).build()
+val type: ParameterizedType = Types.newParameterizedType(List::class.java, Account::class.java)
+val adapter: JsonAdapter<List<Account>> = moshi.adapter(type)
+
+fun getAccountsArray(sp: SharedPreferences): MutableList<Account> {
+    val json = sp.getString(SIP_ACCOUNTS, "")!!
+    return if (json.isNotEmpty())
+        if (json.isEmpty()) mutableListOf() else
+            adapter.fromJson(json)!!.toMutableList()
+    else arrayListOf()
+}
+
+fun getActiveAccount(sp: SharedPreferences): Account? =
+    getAccountsArray(sp).stream().filter { it.isActive }.findFirst().orElse(null)
+
+fun getAccountsJson(list: MutableList<Account>?) = adapter.toJson(list)!!
+
+fun isLetters(string: String): Boolean {
+    return string.matches("^[a-zA-Zа-яА-Я ]*$".toRegex())
+}
+
+fun Context.launchMailClientIntent(email: String) {
+    val emailIntent = Intent(Intent.ACTION_SEND)
+    emailIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
+    emailIntent.type = "vnd.android.cursor.item/email"
+    emailIntent.putExtra(Intent.EXTRA_EMAIL, arrayOf(email))
+//    emailIntent.putExtra(Intent.EXTRA_SUBJECT, "Тема")
+//    emailIntent.putExtra(Intent.EXTRA_TEXT, "Сообщение")
+    startActivity(Intent.createChooser(emailIntent, "Отправить сообщение по почте через..."))
+}
+
+fun Context.launchDialer(number: String) {
+    val intent = Intent(Intent.ACTION_DIAL, Uri.parse("tel:" + Uri.encode(number)))
+    startActivity(intent)
+}
+
+fun setFadeInAnimation(itemView: View, i: Int, onAttach: Boolean) {
+    var p = i
+    if (!onAttach)
+        p = -1
+    val isNotFirstItem = p == -1
+    p++
+    itemView.alpha = 0f
+    val animatorSet = AnimatorSet()
+    val animator = ObjectAnimator.ofFloat(itemView, "alpha", 0f, 0.5f, 1.0f)
+    ObjectAnimator.ofFloat(itemView, "alpha", 0f).start()
+    animator.startDelay = if (isNotFirstItem) DURATION / 2 else p * DURATION / 3
+    animator.duration = 500
+    animatorSet.play(animator)
+    animator.start()
+}
+
+fun setFromLeftToRightAnimation(itemView: View, i: Int, onAttach: Boolean) {
+    var p = i
+    if (!onAttach)
+        p = -1
+    val not_first_item = p == -1
+    p += 1
+    itemView.translationX = -400f
+    itemView.alpha = 0f
+    val animatorSet = AnimatorSet()
+    val animatorTranslateY = ObjectAnimator.ofFloat(itemView, "translationX", -400f, 0f)
+    val animatorAlpha = ObjectAnimator.ofFloat(itemView, "alpha", 1f)
+    ObjectAnimator.ofFloat(itemView, "alpha", 0f).start()
+    animatorTranslateY.startDelay = if (not_first_item) DURATION else p * DURATION
+    animatorTranslateY.duration = (if (not_first_item) 2 else 1) * DURATION
+    animatorSet.playTogether(animatorTranslateY, animatorAlpha)
+    animatorSet.start()
+}
+
+var slideAnimationDuration = 400L
+
+fun View.slideUp() {
+    this.visibility = View.VISIBLE
+    val animate = TranslateAnimation(
+        0F,
+        0F,
+        this.height.toFloat() + 100,
+        0F
+    )
+
+    animate.duration = slideAnimationDuration
+//    animate.fillAfter = true
+    this.startAnimation(animate)
+}
+
+fun View.slideDown() {
+    val animate = TranslateAnimation(
+        0F,  // fromXDelta
+        0F,  // toXDelta
+        0F,  // fromYDelta
+        this.height.toFloat() + 100
+    ) // toYDelta
+
+    animate.duration = slideAnimationDuration
+//    animate.fillAfter = true
+    this.startAnimation(animate)
+    this.visibility = View.GONE
+}
+
+fun View.fadeIn() {
+    this.animate().alpha(1f).setDuration(slideAnimationDuration)
+        .setInterpolator(AccelerateInterpolator()).start()
+}
+
+fun View.fadeOut() {
+    this.animate().alpha(0f).setDuration(slideAnimationDuration)
+        .setInterpolator(AccelerateInterpolator()).start()
+}
+
+fun isOnline(context: Context): Boolean {
+    val connectivityManager =
+        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+    val capabilities =
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) {
+            connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
+        } else {
+            connectivityManager.activeNetworkInfo?.run {
+                return when (type) {
+                    ConnectivityManager.TYPE_WIFI -> true
+                    ConnectivityManager.TYPE_MOBILE -> true
+                    ConnectivityManager.TYPE_ETHERNET -> true
+                    else -> false
+                }
+            }
+        }
+    if (capabilities != null) {
+        when {
+            capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> {
+                Timber.tag("Internet").i("NetworkCapabilities.TRANSPORT_CELLULAR")
+                return true
+            }
+            capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> {
+                Timber.tag("Internet").i("NetworkCapabilities.TRANSPORT_WIFI")
+                return true
+            }
+            capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> {
+                Timber.tag("Internet").i("NetworkCapabilities.TRANSPORT_ETHERNET")
+                return true
+            }
+        }
+    }
+    return false
+}

+ 187 - 0
app/src/main/java/ru/mephi/voip/ui/calls/CallerFragment.kt

@@ -0,0 +1,187 @@
+package ru.mephi.voip.ui.calls
+
+import android.Manifest
+import android.content.pm.PackageManager
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.activity.addCallback
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.content.ContextCompat.checkSelfPermission
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import androidx.navigation.fragment.NavHostFragment
+import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
+import androidx.navigation.ui.AppBarConfiguration
+import androidx.navigation.ui.NavigationUI
+import androidx.recyclerview.widget.ItemTouchHelper
+import androidx.recyclerview.widget.LinearLayoutManager
+import ru.mephi.voip.R
+import ru.mephi.voip.call.CallActivity
+import ru.mephi.voip.data.database.calls.CallRecord
+import ru.mephi.voip.data.utils.*
+import ru.mephi.voip.databinding.FragmentCallsBinding
+import ru.mephi.voip.ui.calls.adapter.CallHistoryAdapter
+import ru.mephi.voip.ui.calls.adapter.SwipeToDeleteCallback
+import timber.log.Timber
+
+class CallerFragment : Fragment() {
+    private lateinit var viewModel: CallerViewModel
+    private lateinit var binding: FragmentCallsBinding
+    private var isPermissionGranted = true
+    var isNumPadUp = false
+
+    private lateinit var historyAdapter: CallHistoryAdapter
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?
+    ): View {
+        Timber.d("onCreateView")
+        binding = FragmentCallsBinding.inflate(inflater, container, false)
+
+        val viewModelProviderFactory =
+            CallerViewModelProviderFactory(requireActivity().application)
+        viewModel = ViewModelProvider(this, viewModelProviderFactory)
+            .get(CallerViewModel::class.java)
+
+        return binding.root
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        Timber.d("onViewCreated")
+        checkPermissions()
+
+        setupToolbar()
+        initViews()
+    }
+
+    private fun checkPermissions() {
+        val permissions = ArrayList<String>()
+        if (checkSelfPermission(requireContext(), Manifest.permission.USE_SIP) != PackageManager.PERMISSION_GRANTED)
+            permissions.add(Manifest.permission.USE_SIP)
+        if (checkSelfPermission(requireContext().applicationContext, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED)
+            permissions.add(Manifest.permission.RECORD_AUDIO)
+
+        isPermissionGranted = permissions.size <= 0
+        if (permissions.size > 0)
+            requestPermissions(
+                permissions.toTypedArray(), 1
+            )
+    }
+
+    private fun tryToCall() {
+        val callNumber = binding.numPad.getInputNumber()
+        CallActivity.create(requireContext(), callNumber, false)
+    }
+
+    private fun initViews() {
+        val args: CallerFragmentArgs by navArgs()
+
+        // Убрать нумпад по кнопке назад, если он отображается
+        requireActivity().onBackPressedDispatcher.addCallback(this) {
+            if (isNumPadUp)
+                changeNumPadVisibility()
+            else
+                requireActivity().finish()
+        }
+        binding.numpadCardview.visibility = View.INVISIBLE
+
+        binding.fabOpenNumpad.setOnClickListener {
+            changeNumPadVisibility()
+        }
+
+        binding.deleteRecords.setOnClickListener {
+            viewModel.deleteAllRecords()
+            updateRecords()
+        }
+
+        binding.numPad.setOnCancelButtonClickListener {
+            changeNumPadVisibility()
+        }
+
+        if (args.callerName.isNotEmpty()) {
+            binding.numPad.setNumber(args.callerName)
+            changeNumPadVisibility()
+        }
+
+        if (isPermissionGranted)
+            binding.callBtn.backgroundTintList = ColorStateList.valueOf(Color.rgb(76, 175, 80))
+        else
+            binding.callBtn.backgroundTintList = ColorStateList.valueOf(Color.GRAY)
+
+        binding.callBtn.setOnClickListener {
+            if (binding.numPad.getInputNumber().length > 3)
+                tryToCall()
+            else
+                toast(getString(R.string.no_name_error))
+        }
+
+        binding.rvCallRecords.layoutManager = LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false)
+
+        historyAdapter = CallHistoryAdapter(requireContext())
+
+        val itemTouchHelper = ItemTouchHelper(SwipeToDeleteCallback(historyAdapter))
+        itemTouchHelper.attachToRecyclerView(binding.rvCallRecords)
+
+        binding.rvCallRecords.adapter = historyAdapter
+    }
+
+    override fun onResume() {
+        super.onResume()
+        updateRecords()
+    }
+
+    private fun updateRecords() {
+        historyAdapter.setRecords(
+            viewModel.getRecords() as MutableList<CallRecord>
+        )
+    }
+
+    private fun changeNumPadVisibility() {
+        if (isNumPadUp) {
+            binding.numpadCardview.slideDown()
+            binding.fabOpenNumpad.fadeIn()
+
+            binding.fabOpenNumpad.setOnClickListener {
+                changeNumPadVisibility()
+            }
+
+            binding.numPad.setOnCancelButtonClickListener {
+                null
+            }
+        } else {
+            binding.numpadCardview.slideUp()
+            binding.fabOpenNumpad.fadeOut()
+
+            binding.fabOpenNumpad.setOnClickListener {
+                null
+            }
+
+            binding.numPad.setOnCancelButtonClickListener {
+                changeNumPadVisibility()
+            }
+        }
+        isNumPadUp = !isNumPadUp
+    }
+
+    override fun onPause() {
+        super.onPause()
+        binding.numPad.clear()
+    }
+
+    private fun setupToolbar() {
+        val navController = findNavController()
+        val appBarConfiguration = AppBarConfiguration(navController.graph)
+        val navHostFragment = NavHostFragment.findNavController(this)
+
+        NavigationUI.setupWithNavController(binding.toolbarCalls, navHostFragment, appBarConfiguration)
+        (activity as AppCompatActivity).setSupportActionBar(binding.toolbarCalls)
+    }
+}

+ 24 - 0
app/src/main/java/ru/mephi/voip/ui/calls/CallerViewModel.kt

@@ -0,0 +1,24 @@
+package ru.mephi.voip.ui.calls
+
+import android.app.Application
+import ru.mephi.voip.data.database.RecordsDao
+import ru.mephi.voip.data.database.calls.CallRecord
+import ru.mephi.voip.data.database.DatabaseViewModel
+import ru.mephi.voip.VoIPApplication
+import ru.mephi.voip.data.network.api.ApiHelper
+
+class CallerViewModel(app: Application) : DatabaseViewModel<CallRecord>(app) {
+
+    override var dao: RecordsDao<CallRecord> =
+        getApplication<VoIPApplication>().callDatabase.getCallRecordsDao()
+
+    override fun getRecords() = dao.getAll()
+
+    override fun addRecord(record: CallRecord) {
+        dao.insertAll(record)
+    }