Initial commit: ContactSync Android app
- SyncAdapter with account type com.bitrix24.contacts.sync - Two-phase sync: download to Room DB, then import to phone contacts - FCM push for instant contact addition - WorkManager: periodic sync (30min) + heartbeat (1h) - QR code activation (JSON format) - Event log UI with LiveData - Auto-update mechanism via version.php - Duplicate contacts cleanup - CALLER_IS_SYNCADAPTER optimization for bulk inserts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
.kotlin
|
||||||
|
/local.properties
|
||||||
|
/.idea
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/app/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Release keystore — keep safe, don't lose!
|
||||||
|
# Uncomment if you want to exclude from repo:
|
||||||
|
# release-keystore.jks
|
||||||
|
|
||||||
|
# Google services config
|
||||||
|
# app/google-services.json
|
||||||
89
app/build.gradle.kts
Normal file
89
app/build.gradle.kts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.ksp)
|
||||||
|
alias(libs.plugins.google.services)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.bitrix24.contactsync"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.bitrix24.contactsync"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
storeFile = file("../release-keystore.jks")
|
||||||
|
storePassword = "ContactSync2024"
|
||||||
|
keyAlias = "contactsync"
|
||||||
|
keyPassword = "ContactSync2024"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.appcompat)
|
||||||
|
implementation(libs.androidx.activity.ktx)
|
||||||
|
implementation(libs.material)
|
||||||
|
|
||||||
|
// Room
|
||||||
|
implementation(libs.room.runtime)
|
||||||
|
implementation(libs.room.ktx)
|
||||||
|
ksp(libs.room.compiler)
|
||||||
|
|
||||||
|
// WorkManager
|
||||||
|
implementation(libs.workmanager)
|
||||||
|
|
||||||
|
// Retrofit + OkHttp
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.retrofit.gson)
|
||||||
|
implementation(libs.okhttp)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
|
// Firebase
|
||||||
|
implementation(platform(libs.firebase.bom))
|
||||||
|
implementation(libs.firebase.messaging)
|
||||||
|
|
||||||
|
// Lifecycle
|
||||||
|
implementation(libs.lifecycle.runtime)
|
||||||
|
implementation(libs.lifecycle.viewmodel)
|
||||||
|
|
||||||
|
// Coroutines
|
||||||
|
implementation(libs.coroutines.core)
|
||||||
|
implementation(libs.coroutines.android)
|
||||||
|
|
||||||
|
// QR Scanner
|
||||||
|
implementation(libs.zxing)
|
||||||
|
}
|
||||||
29
app/google-services.json
Normal file
29
app/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"project_info": {
|
||||||
|
"project_number": "368687825001",
|
||||||
|
"project_id": "bitrix24-contactsyn",
|
||||||
|
"storage_bucket": "bitrix24-contactsyn.firebasestorage.app"
|
||||||
|
},
|
||||||
|
"client": [
|
||||||
|
{
|
||||||
|
"client_info": {
|
||||||
|
"mobilesdk_app_id": "1:368687825001:android:8e744a81d541706c1322f8",
|
||||||
|
"android_client_info": {
|
||||||
|
"package_name": "com.bitrix24.contactsync"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"oauth_client": [],
|
||||||
|
"api_key": [
|
||||||
|
{
|
||||||
|
"current_key": "AIzaSyCgC4KcfsTx9SqZTw0djr8xDn27dJRWXTw"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"services": {
|
||||||
|
"appinvite_service": {
|
||||||
|
"other_platform_oauth_client": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"configuration_version": "1"
|
||||||
|
}
|
||||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Retrofit
|
||||||
|
-keepattributes Signature
|
||||||
|
-keepattributes *Annotation*
|
||||||
|
-keep class com.bitrix24.contactsync.data.api.** { *; }
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn retrofit2.**
|
||||||
|
|
||||||
|
# Room
|
||||||
|
-keep class * extends androidx.room.RoomDatabase
|
||||||
|
-keep @androidx.room.Entity class *
|
||||||
91
app/src/main/AndroidManifest.xml
Normal file
91
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||||
|
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||||
|
<uses-permission android:name="android.permission.AUTHENTICATE_ACCOUNTS" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".App"
|
||||||
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ContactSync">
|
||||||
|
|
||||||
|
<!-- Setup Activity -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.SetupActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:theme="@style/Theme.ContactSync">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- Account Authenticator Service -->
|
||||||
|
<service
|
||||||
|
android:name=".sync.AccountAuthenticatorService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.accounts.AccountAuthenticator" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.accounts.AccountAuthenticator"
|
||||||
|
android:resource="@xml/authenticator" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Sync Adapter Service -->
|
||||||
|
<service
|
||||||
|
android:name=".sync.SyncService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.content.SyncAdapter" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.content.SyncAdapter"
|
||||||
|
android:resource="@xml/syncadapter" />
|
||||||
|
<meta-data
|
||||||
|
android:name="android.provider.CONTACTS_STRUCTURE"
|
||||||
|
android:resource="@xml/contacts" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- FCM Service -->
|
||||||
|
<service
|
||||||
|
android:name=".fcm.FCMService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- FileProvider for APK updates -->
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
|
<!-- Boot Receiver -->
|
||||||
|
<receiver
|
||||||
|
android:name=".receiver.BootReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||||
|
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
47
app/src/main/java/com/bitrix24/contactsync/App.kt
Normal file
47
app/src/main/java/com/bitrix24/contactsync/App.kt
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package com.bitrix24.contactsync
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.worker.HeartbeatWorker
|
||||||
|
import com.bitrix24.contactsync.worker.SyncWorker
|
||||||
|
|
||||||
|
class App : Application() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ContactSyncApp"
|
||||||
|
const val NOTIFICATION_CHANNEL_SYNC = "sync_channel"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
Log.i(TAG, "Application started")
|
||||||
|
|
||||||
|
createNotificationChannels()
|
||||||
|
scheduleWorkersIfActivated()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannels() {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_SYNC,
|
||||||
|
getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
).apply {
|
||||||
|
description = "Уведомления о синхронизации контактов"
|
||||||
|
}
|
||||||
|
|
||||||
|
val manager = getSystemService(NotificationManager::class.java)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleWorkersIfActivated() {
|
||||||
|
val prefs = PrefsManager(this)
|
||||||
|
if (prefs.isActivated) {
|
||||||
|
SyncWorker.schedulePeriodicSync(this)
|
||||||
|
HeartbeatWorker.schedule(this)
|
||||||
|
Log.i(TAG, "Workers scheduled for activated device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.bitrix24.contactsync.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.provider.Settings
|
||||||
|
|
||||||
|
class PrefsManager(context: Context) {
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences =
|
||||||
|
context.getSharedPreferences("contact_sync_prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
|
|
||||||
|
var serverUrl: String
|
||||||
|
get() = prefs.getString(KEY_SERVER_URL, "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString(KEY_SERVER_URL, value).apply()
|
||||||
|
|
||||||
|
var deviceToken: String
|
||||||
|
get() = prefs.getString(KEY_DEVICE_TOKEN, "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString(KEY_DEVICE_TOKEN, value).apply()
|
||||||
|
|
||||||
|
var pipelineId: Int
|
||||||
|
get() = prefs.getInt(KEY_PIPELINE_ID, 0)
|
||||||
|
set(value) = prefs.edit().putInt(KEY_PIPELINE_ID, value).apply()
|
||||||
|
|
||||||
|
var pipelineName: String
|
||||||
|
get() = prefs.getString(KEY_PIPELINE_NAME, "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString(KEY_PIPELINE_NAME, value).apply()
|
||||||
|
|
||||||
|
var fcmToken: String
|
||||||
|
get() = prefs.getString(KEY_FCM_TOKEN, "") ?: ""
|
||||||
|
set(value) = prefs.edit().putString(KEY_FCM_TOKEN, value).apply()
|
||||||
|
|
||||||
|
var lastSyncTime: Long
|
||||||
|
get() = prefs.getLong(KEY_LAST_SYNC_TIME, 0)
|
||||||
|
set(value) = prefs.edit().putLong(KEY_LAST_SYNC_TIME, value).apply()
|
||||||
|
|
||||||
|
var contactsCount: Int
|
||||||
|
get() = prefs.getInt(KEY_CONTACTS_COUNT, 0)
|
||||||
|
set(value) = prefs.edit().putInt(KEY_CONTACTS_COUNT, value).apply()
|
||||||
|
|
||||||
|
val isActivated: Boolean
|
||||||
|
get() = deviceToken.isNotEmpty()
|
||||||
|
|
||||||
|
val deviceId: String
|
||||||
|
get() = Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||||
|
|
||||||
|
val accountName: String
|
||||||
|
get() = if (pipelineName.isNotEmpty()) "Bitrix24 — $pipelineName" else "Bitrix24 Contacts"
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
prefs.edit().clear().apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val KEY_SERVER_URL = "server_url"
|
||||||
|
private const val KEY_DEVICE_TOKEN = "device_token"
|
||||||
|
private const val KEY_PIPELINE_ID = "pipeline_id"
|
||||||
|
private const val KEY_PIPELINE_NAME = "pipeline_name"
|
||||||
|
private const val KEY_FCM_TOKEN = "fcm_token"
|
||||||
|
private const val KEY_LAST_SYNC_TIME = "last_sync_time"
|
||||||
|
private const val KEY_CONTACTS_COUNT = "contacts_count"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package com.bitrix24.contactsync.data.api
|
||||||
|
|
||||||
|
import com.bitrix24.contactsync.BuildConfig
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor
|
||||||
|
import retrofit2.Retrofit
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
object ApiClient {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: ApiService? = null
|
||||||
|
private var currentBaseUrl: String? = null
|
||||||
|
|
||||||
|
fun getInstance(baseUrl: String): ApiService {
|
||||||
|
val normalizedUrl = normalizeBaseUrl(baseUrl)
|
||||||
|
if (instance != null && currentBaseUrl == normalizedUrl) {
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
synchronized(this) {
|
||||||
|
if (instance != null && currentBaseUrl == normalizedUrl) {
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
|
||||||
|
val clientBuilder = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
|
||||||
|
if (BuildConfig.DEBUG) {
|
||||||
|
val logging = HttpLoggingInterceptor().apply {
|
||||||
|
level = HttpLoggingInterceptor.Level.BODY
|
||||||
|
}
|
||||||
|
clientBuilder.addInterceptor(logging)
|
||||||
|
}
|
||||||
|
|
||||||
|
val client = clientBuilder.build()
|
||||||
|
|
||||||
|
val retrofit = Retrofit.Builder()
|
||||||
|
.baseUrl(normalizedUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
instance = retrofit.create(ApiService::class.java)
|
||||||
|
currentBaseUrl = normalizedUrl
|
||||||
|
return instance!!
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeBaseUrl(url: String): String {
|
||||||
|
var result = url.trim()
|
||||||
|
if (!result.startsWith("http://") && !result.startsWith("https://")) {
|
||||||
|
result = "https://$result"
|
||||||
|
}
|
||||||
|
if (!result.endsWith("/")) {
|
||||||
|
result = "$result/"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.bitrix24.contactsync.data.api
|
||||||
|
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ApiService {
|
||||||
|
|
||||||
|
@POST("register.php")
|
||||||
|
suspend fun register(@Body request: RegisterRequest): RegisterResponse
|
||||||
|
|
||||||
|
@GET("contacts.php")
|
||||||
|
suspend fun getContacts(
|
||||||
|
@Query("device_token") deviceToken: String,
|
||||||
|
@Query("since") since: Long? = null,
|
||||||
|
@Query("offset") offset: Int = 0,
|
||||||
|
@Query("limit") limit: Int = 500
|
||||||
|
): ContactsResponse
|
||||||
|
|
||||||
|
@POST("heartbeat.php")
|
||||||
|
suspend fun heartbeat(@Body request: HeartbeatRequest): HeartbeatResponse
|
||||||
|
|
||||||
|
@GET("version.php")
|
||||||
|
suspend fun checkVersion(): VersionResponse
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package com.bitrix24.contactsync.data.api
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName
|
||||||
|
|
||||||
|
// --- Request models ---
|
||||||
|
|
||||||
|
data class RegisterRequest(
|
||||||
|
@SerializedName("device_id") val deviceId: String,
|
||||||
|
@SerializedName("activation_code") val activationCode: String,
|
||||||
|
@SerializedName("fcm_token") val fcmToken: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HeartbeatRequest(
|
||||||
|
@SerializedName("device_token") val deviceToken: String,
|
||||||
|
@SerializedName("fcm_token") val fcmToken: String,
|
||||||
|
@SerializedName("contacts_count") val contactsCount: Int,
|
||||||
|
@SerializedName("last_sync") val lastSync: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- Response models ---
|
||||||
|
|
||||||
|
data class RegisterResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
@SerializedName("device_token") val deviceToken: String?,
|
||||||
|
@SerializedName("pipeline_id") val pipelineId: Int?,
|
||||||
|
@SerializedName("pipeline_name") val pipelineName: String?,
|
||||||
|
val error: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContactsResponse(
|
||||||
|
val contacts: List<ContactData>,
|
||||||
|
val total: Int,
|
||||||
|
@SerializedName("has_more") val hasMore: Boolean,
|
||||||
|
@SerializedName("sync_timestamp") val syncTimestamp: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContactData(
|
||||||
|
val id: Long,
|
||||||
|
val name: String,
|
||||||
|
val phones: List<String>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class HeartbeatResponse(
|
||||||
|
val success: Boolean,
|
||||||
|
@SerializedName("force_sync") val forceSync: Boolean = false
|
||||||
|
)
|
||||||
|
|
||||||
|
data class VersionResponse(
|
||||||
|
@SerializedName("version_code") val versionCode: Int,
|
||||||
|
@SerializedName("version_name") val versionName: String,
|
||||||
|
val url: String,
|
||||||
|
val mandatory: Boolean = false
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.bitrix24.contactsync.data.db
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
@Database(
|
||||||
|
entities = [SyncState::class, SyncedContact::class, EventLog::class],
|
||||||
|
version = 3,
|
||||||
|
exportSchema = false
|
||||||
|
)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun syncStateDao(): SyncStateDao
|
||||||
|
abstract fun syncedContactDao(): SyncedContactDao
|
||||||
|
abstract fun eventLogDao(): EventLogDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): AppDatabase {
|
||||||
|
return instance ?: synchronized(this) {
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(db: SupportSQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS event_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
instance ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"contact_sync.db"
|
||||||
|
).addMigrations(MIGRATION_2_3)
|
||||||
|
.fallbackToDestructiveMigration()
|
||||||
|
.build().also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.bitrix24.contactsync.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "event_log")
|
||||||
|
data class EventLog(
|
||||||
|
@PrimaryKey(autoGenerate = true)
|
||||||
|
val id: Long = 0,
|
||||||
|
val type: String, // "push", "sync", "error"
|
||||||
|
val message: String,
|
||||||
|
val timestamp: Long = System.currentTimeMillis()
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val TYPE_PUSH = "push"
|
||||||
|
const val TYPE_SYNC = "sync"
|
||||||
|
const val TYPE_ERROR = "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.bitrix24.contactsync.data.db
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.room.*
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface EventLogDao {
|
||||||
|
|
||||||
|
@Insert
|
||||||
|
suspend fun insert(event: EventLog)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM event_log ORDER BY timestamp DESC LIMIT 100")
|
||||||
|
fun getRecentLive(): LiveData<List<EventLog>>
|
||||||
|
|
||||||
|
@Query("DELETE FROM event_log WHERE id NOT IN (SELECT id FROM event_log ORDER BY timestamp DESC LIMIT 100)")
|
||||||
|
suspend fun trimOld()
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.bitrix24.contactsync.data.db
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
@Entity(tableName = "sync_state")
|
||||||
|
data class SyncState(
|
||||||
|
@PrimaryKey
|
||||||
|
val key: String,
|
||||||
|
val value: String,
|
||||||
|
val updatedAt: Long = System.currentTimeMillis()
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
||||||
|
const val KEY_CONTACTS_COUNT = "contacts_count"
|
||||||
|
const val KEY_LAST_SYNC_STATUS = "last_sync_status"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity(tableName = "synced_contacts")
|
||||||
|
data class SyncedContact(
|
||||||
|
@PrimaryKey
|
||||||
|
val bitrixId: Long,
|
||||||
|
val name: String,
|
||||||
|
val phones: String, // JSON array
|
||||||
|
val rawContactId: Long = 0,
|
||||||
|
val imported: Boolean = false,
|
||||||
|
val syncedAt: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package com.bitrix24.contactsync.data.db
|
||||||
|
|
||||||
|
import androidx.room.*
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SyncStateDao {
|
||||||
|
|
||||||
|
@Query("SELECT value FROM sync_state WHERE `key` = :key")
|
||||||
|
suspend fun getValue(key: String): String?
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun setValue(state: SyncState)
|
||||||
|
|
||||||
|
suspend fun getLastSyncTimestamp(): Long {
|
||||||
|
return getValue(SyncState.KEY_LAST_SYNC_TIMESTAMP)?.toLongOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setLastSyncTimestamp(timestamp: Long) {
|
||||||
|
setValue(SyncState(SyncState.KEY_LAST_SYNC_TIMESTAMP, timestamp.toString()))
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getContactsCount(): Int {
|
||||||
|
return getValue(SyncState.KEY_CONTACTS_COUNT)?.toIntOrNull() ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setContactsCount(count: Int) {
|
||||||
|
setValue(SyncState(SyncState.KEY_CONTACTS_COUNT, count.toString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Dao
|
||||||
|
interface SyncedContactDao {
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(contact: SyncedContact)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsertAll(contacts: List<SyncedContact>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM synced_contacts WHERE bitrixId = :bitrixId")
|
||||||
|
suspend fun getByBitrixId(bitrixId: Long): SyncedContact?
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM synced_contacts")
|
||||||
|
suspend fun getCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM synced_contacts WHERE imported = 0")
|
||||||
|
suspend fun getNotImportedCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM synced_contacts WHERE imported = 1")
|
||||||
|
suspend fun getImportedCount(): Int
|
||||||
|
|
||||||
|
@Query("SELECT * FROM synced_contacts WHERE imported = 0 LIMIT :limit")
|
||||||
|
suspend fun getNotImported(limit: Int): List<SyncedContact>
|
||||||
|
|
||||||
|
@Query("UPDATE synced_contacts SET imported = 1, rawContactId = :rawContactId WHERE bitrixId = :bitrixId")
|
||||||
|
suspend fun markImported(bitrixId: Long, rawContactId: Long)
|
||||||
|
|
||||||
|
@Query("UPDATE synced_contacts SET imported = 1 WHERE bitrixId IN (:ids)")
|
||||||
|
suspend fun markImportedBatch(ids: List<Long>)
|
||||||
|
|
||||||
|
@Query("SELECT * FROM synced_contacts")
|
||||||
|
suspend fun getAll(): List<SyncedContact>
|
||||||
|
|
||||||
|
@Query("DELETE FROM synced_contacts")
|
||||||
|
suspend fun deleteAll()
|
||||||
|
}
|
||||||
102
app/src/main/java/com/bitrix24/contactsync/fcm/FCMService.kt
Normal file
102
app/src/main/java/com/bitrix24/contactsync/fcm/FCMService.kt
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package com.bitrix24.contactsync.fcm
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.data.api.ContactData
|
||||||
|
import com.bitrix24.contactsync.data.db.AppDatabase
|
||||||
|
import com.bitrix24.contactsync.data.db.EventLog
|
||||||
|
import com.bitrix24.contactsync.sync.AccountAuthenticatorService
|
||||||
|
import com.bitrix24.contactsync.sync.ContactsManager
|
||||||
|
import com.google.firebase.messaging.FirebaseMessagingService
|
||||||
|
import com.google.firebase.messaging.RemoteMessage
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class FCMService : FirebaseMessagingService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "FCMService"
|
||||||
|
private const val ACTION_ADD_CONTACT = "add_contact"
|
||||||
|
private const val ACTION_FORCE_SYNC = "force_sync"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
|
|
||||||
|
override fun onNewToken(token: String) {
|
||||||
|
Log.d(TAG, "New FCM token: $token")
|
||||||
|
val prefs = PrefsManager(this)
|
||||||
|
prefs.fcmToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
|
Log.d(TAG, "FCM message received: ${message.data}")
|
||||||
|
val data = message.data
|
||||||
|
|
||||||
|
when (data["action"]) {
|
||||||
|
ACTION_ADD_CONTACT -> handleAddContact(data)
|
||||||
|
ACTION_FORCE_SYNC -> handleForceSync()
|
||||||
|
else -> Log.w(TAG, "Unknown action: ${data["action"]}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleAddContact(data: Map<String, String>) {
|
||||||
|
val prefs = PrefsManager(this)
|
||||||
|
if (!prefs.isActivated) return
|
||||||
|
|
||||||
|
val account = AccountAuthenticatorService.getAccount(this) ?: return
|
||||||
|
|
||||||
|
val contactId = data["contact_id"]?.toLongOrNull() ?: return
|
||||||
|
val name = data["name"] ?: return
|
||||||
|
val phonesJson = data["phones"] ?: "[]"
|
||||||
|
|
||||||
|
val phones: List<String> = try {
|
||||||
|
val type = object : TypeToken<List<String>>() {}.type
|
||||||
|
Gson().fromJson(phonesJson, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
phonesJson.removeSurrounding("[", "]")
|
||||||
|
.split(",")
|
||||||
|
.map { it.trim().removeSurrounding("'").removeSurrounding("\"") }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val contact = ContactData(id = contactId, name = name, phones = phones)
|
||||||
|
|
||||||
|
try {
|
||||||
|
val contactsManager = ContactsManager(this)
|
||||||
|
contactsManager.upsertContact(account, contact)
|
||||||
|
Log.i(TAG, "Contact added/updated via FCM: $contactId - $name")
|
||||||
|
prefs.contactsCount = prefs.contactsCount + 1
|
||||||
|
|
||||||
|
logEvent(EventLog.TYPE_PUSH, "Push: $name (${phones.joinToString()})")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to add contact from FCM", e)
|
||||||
|
logEvent(EventLog.TYPE_ERROR, "Ошибка push: $name — ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleForceSync() {
|
||||||
|
Log.i(TAG, "Force sync requested via FCM")
|
||||||
|
logEvent(EventLog.TYPE_SYNC, "Запрошена принудительная синхронизация")
|
||||||
|
val account = AccountAuthenticatorService.getAccount(this) ?: return
|
||||||
|
android.content.ContentResolver.requestSync(
|
||||||
|
account,
|
||||||
|
android.provider.ContactsContract.AUTHORITY,
|
||||||
|
android.os.Bundle.EMPTY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logEvent(type: String, message: String) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val db = AppDatabase.getInstance(this@FCMService)
|
||||||
|
db.eventLogDao().insert(EventLog(type = type, message = message))
|
||||||
|
db.eventLogDao().trimOld()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to log event", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.bitrix24.contactsync.receiver
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.worker.HeartbeatWorker
|
||||||
|
import com.bitrix24.contactsync.worker.SyncWorker
|
||||||
|
|
||||||
|
class BootReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BootReceiver"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
|
||||||
|
intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Boot/update received, re-scheduling workers")
|
||||||
|
|
||||||
|
val prefs = PrefsManager(context)
|
||||||
|
if (!prefs.isActivated) {
|
||||||
|
Log.i(TAG, "Device not activated, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SyncWorker.schedulePeriodicSync(context)
|
||||||
|
HeartbeatWorker.schedule(context)
|
||||||
|
|
||||||
|
// Request immediate sync after boot
|
||||||
|
SyncWorker.requestImmediateSync(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package com.bitrix24.contactsync.sync
|
||||||
|
|
||||||
|
import android.accounts.*
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
|
||||||
|
class AccountAuthenticatorService : Service() {
|
||||||
|
|
||||||
|
private lateinit var authenticator: StubAuthenticator
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
authenticator = StubAuthenticator(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = authenticator.iBinder
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACCOUNT_TYPE = "com.bitrix24.contacts.sync"
|
||||||
|
|
||||||
|
fun createAccount(context: Context, accountName: String): Account {
|
||||||
|
val account = Account(accountName, ACCOUNT_TYPE)
|
||||||
|
val accountManager = AccountManager.get(context)
|
||||||
|
val existing = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||||
|
if (existing.isNotEmpty()) {
|
||||||
|
return existing[0]
|
||||||
|
}
|
||||||
|
accountManager.addAccountExplicitly(account, null, null)
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAccount(context: Context): Account? {
|
||||||
|
val accountManager = AccountManager.get(context)
|
||||||
|
val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||||
|
return accounts.firstOrNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAccount(context: Context) {
|
||||||
|
val accountManager = AccountManager.get(context)
|
||||||
|
val accounts = accountManager.getAccountsByType(ACCOUNT_TYPE)
|
||||||
|
for (account in accounts) {
|
||||||
|
accountManager.removeAccountExplicitly(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub authenticator — we don't need real authentication,
|
||||||
|
* but SyncAdapter requires an AccountAuthenticator.
|
||||||
|
*/
|
||||||
|
private class StubAuthenticator(context: Context) : AbstractAccountAuthenticator(context) {
|
||||||
|
|
||||||
|
override fun editProperties(response: AccountAuthenticatorResponse?, accountType: String?): Bundle {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun addAccount(
|
||||||
|
response: AccountAuthenticatorResponse?,
|
||||||
|
accountType: String?,
|
||||||
|
authTokenType: String?,
|
||||||
|
requiredFeatures: Array<out String>?,
|
||||||
|
options: Bundle?
|
||||||
|
): Bundle? = null
|
||||||
|
|
||||||
|
override fun confirmCredentials(
|
||||||
|
response: AccountAuthenticatorResponse?,
|
||||||
|
account: Account?,
|
||||||
|
options: Bundle?
|
||||||
|
): Bundle? = null
|
||||||
|
|
||||||
|
override fun getAuthToken(
|
||||||
|
response: AccountAuthenticatorResponse?,
|
||||||
|
account: Account?,
|
||||||
|
authTokenType: String?,
|
||||||
|
options: Bundle?
|
||||||
|
): Bundle {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAuthTokenLabel(authTokenType: String?): String {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun updateCredentials(
|
||||||
|
response: AccountAuthenticatorResponse?,
|
||||||
|
account: Account?,
|
||||||
|
authTokenType: String?,
|
||||||
|
options: Bundle?
|
||||||
|
): Bundle {
|
||||||
|
throw UnsupportedOperationException()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hasFeatures(
|
||||||
|
response: AccountAuthenticatorResponse?,
|
||||||
|
account: Account?,
|
||||||
|
features: Array<out String>?
|
||||||
|
): Bundle {
|
||||||
|
return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package com.bitrix24.contactsync.sync
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.ContentProviderOperation
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
|
import android.provider.ContactsContract
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.Phone
|
||||||
|
import android.provider.ContactsContract.CommonDataKinds.StructuredName
|
||||||
|
import android.provider.ContactsContract.Data
|
||||||
|
import android.provider.ContactsContract.RawContacts
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitrix24.contactsync.data.api.ContactData
|
||||||
|
|
||||||
|
class ContactsManager(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ContactsManager"
|
||||||
|
// With CALLER_IS_SYNCADAPTER + yield every 10 contacts (~30 ops)
|
||||||
|
// we can safely do 500 contacts per applyBatch
|
||||||
|
private const val BULK_BATCH_SIZE = 500
|
||||||
|
private const val DELTA_BATCH_SIZE = 100
|
||||||
|
private const val YIELD_EVERY_N_CONTACTS = 10
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncAdapter URIs — skip aggregation and notifications for speed
|
||||||
|
private val rawContactsSyncUri: Uri = RawContacts.CONTENT_URI.buildUpon()
|
||||||
|
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val dataSyncUri: Uri = Data.CONTENT_URI.buildUpon()
|
||||||
|
.appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or update a single contact.
|
||||||
|
*/
|
||||||
|
fun upsertContact(account: Account, contact: ContactData): Long {
|
||||||
|
val existingRawContactId = findRawContactByBitrixId(account, contact.id)
|
||||||
|
return if (existingRawContactId != null) {
|
||||||
|
updateContact(existingRawContactId, contact)
|
||||||
|
existingRawContactId
|
||||||
|
} else {
|
||||||
|
insertContact(account, contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast bulk insert — no duplicate checks, CALLER_IS_SYNCADAPTER, sparse yields.
|
||||||
|
* Use for initial sync only.
|
||||||
|
*/
|
||||||
|
fun bulkInsertContacts(account: Account, contacts: List<ContactData>): Int {
|
||||||
|
var count = 0
|
||||||
|
for (chunk in contacts.chunked(BULK_BATCH_SIZE)) {
|
||||||
|
val ops = ArrayList<ContentProviderOperation>(chunk.size * 4)
|
||||||
|
for ((index, contact) in chunk.withIndex()) {
|
||||||
|
val needYield = (index % YIELD_EVERY_N_CONTACTS == 0)
|
||||||
|
buildFastInsertOps(account, contact, ops, needYield)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||||
|
count += chunk.size
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Bulk insert failed (${chunk.size} contacts, ${ops.size} ops), splitting", e)
|
||||||
|
// Split in half and retry
|
||||||
|
val half = chunk.size / 2
|
||||||
|
if (half > 0) {
|
||||||
|
count += bulkInsertContacts(account, chunk.subList(0, half))
|
||||||
|
count += bulkInsertContacts(account, chunk.subList(half, chunk.size))
|
||||||
|
} else {
|
||||||
|
// Single contact, try individually
|
||||||
|
for (contact in chunk) {
|
||||||
|
try {
|
||||||
|
insertContact(account, contact)
|
||||||
|
count++
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e(TAG, "Failed to insert contact ${contact.id}", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch insert/update with duplicate checks. Use for delta sync.
|
||||||
|
*/
|
||||||
|
fun upsertContacts(account: Account, contacts: List<ContactData>): Int {
|
||||||
|
var count = 0
|
||||||
|
for (chunk in contacts.chunked(DELTA_BATCH_SIZE)) {
|
||||||
|
val ops = ArrayList<ContentProviderOperation>()
|
||||||
|
for ((index, contact) in chunk.withIndex()) {
|
||||||
|
val existingRawContactId = findRawContactByBitrixId(account, contact.id)
|
||||||
|
if (existingRawContactId != null) {
|
||||||
|
buildUpdateOps(existingRawContactId, contact, ops)
|
||||||
|
} else {
|
||||||
|
val needYield = (index % YIELD_EVERY_N_CONTACTS == 0)
|
||||||
|
buildFastInsertOps(account, contact, ops, needYield)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ops.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||||
|
count += chunk.size
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Batch upsert failed, retrying individually", e)
|
||||||
|
for (contact in chunk) {
|
||||||
|
try {
|
||||||
|
upsertContact(account, contact)
|
||||||
|
count++
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e(TAG, "Failed to upsert contact ${contact.id}", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insertContact(account: Account, contact: ContactData): Long {
|
||||||
|
val ops = ArrayList<ContentProviderOperation>()
|
||||||
|
buildFastInsertOps(account, contact, ops, true)
|
||||||
|
val results = context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||||
|
return results[0].uri?.lastPathSegment?.toLongOrNull() ?: 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build insert ops using SyncAdapter URIs (skips aggregation).
|
||||||
|
* Yield point only when needYield=true.
|
||||||
|
*/
|
||||||
|
private fun buildFastInsertOps(
|
||||||
|
account: Account,
|
||||||
|
contact: ContactData,
|
||||||
|
ops: ArrayList<ContentProviderOperation>,
|
||||||
|
needYield: Boolean
|
||||||
|
) {
|
||||||
|
val backRef = ops.size
|
||||||
|
|
||||||
|
val rawOp = ContentProviderOperation.newInsert(rawContactsSyncUri)
|
||||||
|
.withValue(RawContacts.ACCOUNT_TYPE, account.type)
|
||||||
|
.withValue(RawContacts.ACCOUNT_NAME, account.name)
|
||||||
|
.withValue(RawContacts.SYNC1, contact.id.toString())
|
||||||
|
.withValue(RawContacts.SYNC2, System.currentTimeMillis().toString())
|
||||||
|
.withValue(RawContacts.AGGREGATION_MODE, RawContacts.AGGREGATION_MODE_SUSPENDED)
|
||||||
|
|
||||||
|
if (needYield) rawOp.withYieldAllowed(true)
|
||||||
|
|
||||||
|
ops.add(rawOp.build())
|
||||||
|
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newInsert(dataSyncUri)
|
||||||
|
.withValueBackReference(Data.RAW_CONTACT_ID, backRef)
|
||||||
|
.withValue(Data.MIMETYPE, StructuredName.CONTENT_ITEM_TYPE)
|
||||||
|
.withValue(StructuredName.DISPLAY_NAME, contact.name)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
for (phone in contact.phones) {
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newInsert(dataSyncUri)
|
||||||
|
.withValueBackReference(Data.RAW_CONTACT_ID, backRef)
|
||||||
|
.withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
|
||||||
|
.withValue(Phone.NUMBER, phone)
|
||||||
|
.withValue(Phone.TYPE, Phone.TYPE_MOBILE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateContact(rawContactId: Long, contact: ContactData) {
|
||||||
|
val ops = ArrayList<ContentProviderOperation>()
|
||||||
|
buildUpdateOps(rawContactId, contact, ops)
|
||||||
|
if (ops.isNotEmpty()) {
|
||||||
|
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUpdateOps(
|
||||||
|
rawContactId: Long,
|
||||||
|
contact: ContactData,
|
||||||
|
ops: ArrayList<ContentProviderOperation>
|
||||||
|
) {
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newUpdate(dataSyncUri)
|
||||||
|
.withSelection(
|
||||||
|
"${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(rawContactId.toString(), StructuredName.CONTENT_ITEM_TYPE)
|
||||||
|
)
|
||||||
|
.withValue(StructuredName.DISPLAY_NAME, contact.name)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newDelete(dataSyncUri)
|
||||||
|
.withSelection(
|
||||||
|
"${Data.RAW_CONTACT_ID} = ? AND ${Data.MIMETYPE} = ?",
|
||||||
|
arrayOf(rawContactId.toString(), Phone.CONTENT_ITEM_TYPE)
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
for (phone in contact.phones) {
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newInsert(dataSyncUri)
|
||||||
|
.withValue(Data.RAW_CONTACT_ID, rawContactId)
|
||||||
|
.withValue(Data.MIMETYPE, Phone.CONTENT_ITEM_TYPE)
|
||||||
|
.withValue(Phone.NUMBER, phone)
|
||||||
|
.withValue(Phone.TYPE, Phone.TYPE_MOBILE)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newUpdate(rawContactsSyncUri)
|
||||||
|
.withSelection("${RawContacts._ID} = ?", arrayOf(rawContactId.toString()))
|
||||||
|
.withValue(RawContacts.SYNC2, System.currentTimeMillis().toString())
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun findRawContactByBitrixId(account: Account, bitrixId: Long): Long? {
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
RawContacts.CONTENT_URI,
|
||||||
|
arrayOf(RawContacts._ID),
|
||||||
|
"${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ? AND ${RawContacts.SYNC1} = ? AND ${RawContacts.DELETED} = 0",
|
||||||
|
arrayOf(account.type, account.name, bitrixId.toString()),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
cursor?.use {
|
||||||
|
if (it.moveToFirst()) return it.getLong(0)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove duplicate contacts — keeps the oldest rawContactId per SYNC1,
|
||||||
|
* deletes all newer duplicates.
|
||||||
|
*/
|
||||||
|
fun removeDuplicates(account: Account): Int {
|
||||||
|
// Collect all SYNC1 → list of rawContactIds
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
RawContacts.CONTENT_URI,
|
||||||
|
arrayOf(RawContacts._ID, RawContacts.SYNC1),
|
||||||
|
"${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ? AND ${RawContacts.DELETED} = 0",
|
||||||
|
arrayOf(account.type, account.name),
|
||||||
|
"${RawContacts.SYNC1} ASC, ${RawContacts._ID} ASC"
|
||||||
|
)
|
||||||
|
|
||||||
|
val duplicateIds = mutableListOf<Long>()
|
||||||
|
val seen = mutableMapOf<String, Long>() // SYNC1 → first rawContactId
|
||||||
|
|
||||||
|
cursor?.use {
|
||||||
|
val idCol = it.getColumnIndexOrThrow(RawContacts._ID)
|
||||||
|
val sync1Col = it.getColumnIndexOrThrow(RawContacts.SYNC1)
|
||||||
|
while (it.moveToNext()) {
|
||||||
|
val rawId = it.getLong(idCol)
|
||||||
|
val sync1 = it.getString(sync1Col) ?: continue
|
||||||
|
if (seen.containsKey(sync1)) {
|
||||||
|
duplicateIds.add(rawId)
|
||||||
|
} else {
|
||||||
|
seen[sync1] = rawId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duplicateIds.isEmpty()) {
|
||||||
|
Log.i(TAG, "No duplicates found")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Found ${duplicateIds.size} duplicates, removing...")
|
||||||
|
|
||||||
|
// Delete in batches
|
||||||
|
var deleted = 0
|
||||||
|
for (chunk in duplicateIds.chunked(100)) {
|
||||||
|
val ops = ArrayList<ContentProviderOperation>()
|
||||||
|
for (id in chunk) {
|
||||||
|
ops.add(
|
||||||
|
ContentProviderOperation.newDelete(rawContactsSyncUri)
|
||||||
|
.withSelection("${RawContacts._ID} = ?", arrayOf(id.toString()))
|
||||||
|
.withYieldAllowed(true)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
|
||||||
|
deleted += chunk.size
|
||||||
|
Log.i(TAG, "Deleted duplicates: $deleted / ${duplicateIds.size}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to delete duplicate batch", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "Duplicate removal complete: $deleted removed")
|
||||||
|
return deleted
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getContactsCount(account: Account): Int {
|
||||||
|
val cursor: Cursor? = context.contentResolver.query(
|
||||||
|
RawContacts.CONTENT_URI,
|
||||||
|
arrayOf(RawContacts._ID),
|
||||||
|
"${RawContacts.ACCOUNT_TYPE} = ? AND ${RawContacts.ACCOUNT_NAME} = ? AND ${RawContacts.DELETED} = 0",
|
||||||
|
arrayOf(account.type, account.name),
|
||||||
|
null
|
||||||
|
)
|
||||||
|
val count = cursor?.count ?: 0
|
||||||
|
cursor?.close()
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package com.bitrix24.contactsync.sync
|
||||||
|
|
||||||
|
import android.accounts.Account
|
||||||
|
import android.content.*
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitrix24.contactsync.worker.SyncWorker
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SyncAdapter stub — delegates actual work to SyncWorker to avoid
|
||||||
|
* parallel sync conflicts. Exists only to register the account type.
|
||||||
|
*/
|
||||||
|
class SyncAdapter(
|
||||||
|
context: Context,
|
||||||
|
autoInitialize: Boolean
|
||||||
|
) : AbstractThreadedSyncAdapter(context, autoInitialize) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncAdapter"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPerformSync(
|
||||||
|
account: Account,
|
||||||
|
extras: Bundle,
|
||||||
|
authority: String,
|
||||||
|
provider: ContentProviderClient,
|
||||||
|
syncResult: SyncResult
|
||||||
|
) {
|
||||||
|
Log.i(TAG, "SyncAdapter triggered, delegating to SyncWorker")
|
||||||
|
SyncWorker.requestImmediateSync(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.bitrix24.contactsync.sync
|
||||||
|
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
|
||||||
|
class SyncService : Service() {
|
||||||
|
|
||||||
|
private lateinit var syncAdapter: SyncAdapter
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
synchronized(lock) {
|
||||||
|
syncAdapter = SyncAdapter(applicationContext, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder = syncAdapter.syncAdapterBinder
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val lock = Any()
|
||||||
|
}
|
||||||
|
}
|
||||||
118
app/src/main/java/com/bitrix24/contactsync/ui/AppUpdater.kt
Normal file
118
app/src/main/java/com/bitrix24/contactsync/ui/AppUpdater.kt
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package com.bitrix24.contactsync.ui
|
||||||
|
|
||||||
|
import android.app.DownloadManager
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Environment
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import com.bitrix24.contactsync.BuildConfig
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.data.api.ApiClient
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class AppUpdater(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AppUpdater"
|
||||||
|
private const val APK_FILENAME = "contactsync-update.apk"
|
||||||
|
}
|
||||||
|
|
||||||
|
data class UpdateInfo(
|
||||||
|
val versionCode: Int,
|
||||||
|
val versionName: String,
|
||||||
|
val url: String,
|
||||||
|
val mandatory: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if update is available. Returns UpdateInfo or null.
|
||||||
|
*/
|
||||||
|
suspend fun checkForUpdate(): UpdateInfo? {
|
||||||
|
val prefs = PrefsManager(context)
|
||||||
|
if (!prefs.isActivated) return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val api = ApiClient.getInstance(prefs.serverUrl)
|
||||||
|
val response = api.checkVersion()
|
||||||
|
|
||||||
|
val currentVersion = BuildConfig.VERSION_CODE
|
||||||
|
if (response.versionCode > currentVersion) {
|
||||||
|
Log.i(TAG, "Update available: ${response.versionName} (${response.versionCode}), current: $currentVersion")
|
||||||
|
UpdateInfo(
|
||||||
|
versionCode = response.versionCode,
|
||||||
|
versionName = response.versionName,
|
||||||
|
url = response.url,
|
||||||
|
mandatory = response.mandatory
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "App is up to date (v$currentVersion)")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to check for updates", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download and install APK update.
|
||||||
|
*/
|
||||||
|
fun downloadAndInstall(update: UpdateInfo) {
|
||||||
|
// Clean old APK if exists
|
||||||
|
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILENAME)
|
||||||
|
if (apkFile.exists()) apkFile.delete()
|
||||||
|
|
||||||
|
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
|
|
||||||
|
val request = DownloadManager.Request(Uri.parse(update.url))
|
||||||
|
.setTitle("Обновление ContactSync ${update.versionName}")
|
||||||
|
.setDescription("Загрузка обновления...")
|
||||||
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, APK_FILENAME)
|
||||||
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||||
|
|
||||||
|
val downloadId = downloadManager.enqueue(request)
|
||||||
|
Log.i(TAG, "Download started: id=$downloadId, url=${update.url}")
|
||||||
|
|
||||||
|
// Listen for download completion
|
||||||
|
val receiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(ctx: Context, intent: Intent) {
|
||||||
|
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||||
|
if (id == downloadId) {
|
||||||
|
ctx.unregisterReceiver(this)
|
||||||
|
installApk(apkFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context.registerReceiver(
|
||||||
|
receiver,
|
||||||
|
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||||
|
Context.RECEIVER_EXPORTED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun installApk(apkFile: File) {
|
||||||
|
if (!apkFile.exists()) {
|
||||||
|
Log.e(TAG, "APK file not found: ${apkFile.absolutePath}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = FileProvider.getUriForFile(
|
||||||
|
context,
|
||||||
|
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||||
|
apkFile
|
||||||
|
)
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
|
setDataAndType(uri, "application/vnd.android.package-archive")
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
}
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
Log.i(TAG, "Install intent launched")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.bitrix24.contactsync.ui
|
||||||
|
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.bitrix24.contactsync.data.db.EventLog
|
||||||
|
import com.bitrix24.contactsync.databinding.ItemEventLogBinding
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class EventLogAdapter : ListAdapter<EventLog, EventLogAdapter.ViewHolder>(DIFF) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val DIFF = object : DiffUtil.ItemCallback<EventLog>() {
|
||||||
|
override fun areItemsTheSame(a: EventLog, b: EventLog) = a.id == b.id
|
||||||
|
override fun areContentsTheSame(a: EventLog, b: EventLog) = a == b
|
||||||
|
}
|
||||||
|
private val timeFmt = SimpleDateFormat("dd.MM HH:mm:ss", Locale.getDefault())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val binding = ItemEventLogBinding.inflate(
|
||||||
|
LayoutInflater.from(parent.context), parent, false
|
||||||
|
)
|
||||||
|
return ViewHolder(binding)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||||
|
holder.bind(getItem(position))
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHolder(private val b: ItemEventLogBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(event: EventLog) {
|
||||||
|
b.txtIcon.text = when (event.type) {
|
||||||
|
EventLog.TYPE_PUSH -> "\u2709" // ✉
|
||||||
|
EventLog.TYPE_SYNC -> "\u21BB" // ↻
|
||||||
|
EventLog.TYPE_ERROR -> "\u26A0" // ⚠
|
||||||
|
else -> "\u2022" // •
|
||||||
|
}
|
||||||
|
b.txtMessage.text = event.message
|
||||||
|
b.txtTime.text = timeFmt.format(Date(event.timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
389
app/src/main/java/com/bitrix24/contactsync/ui/SetupActivity.kt
Normal file
389
app/src/main/java/com/bitrix24/contactsync/ui/SetupActivity.kt
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
package com.bitrix24.contactsync.ui
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import com.bitrix24.contactsync.BuildConfig
|
||||||
|
import com.bitrix24.contactsync.R
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.data.api.ApiClient
|
||||||
|
import com.bitrix24.contactsync.data.api.RegisterRequest
|
||||||
|
import com.bitrix24.contactsync.data.db.AppDatabase
|
||||||
|
import com.bitrix24.contactsync.databinding.ActivitySetupBinding
|
||||||
|
import com.bitrix24.contactsync.sync.AccountAuthenticatorService
|
||||||
|
import com.bitrix24.contactsync.worker.HeartbeatWorker
|
||||||
|
import com.bitrix24.contactsync.worker.SyncWorker
|
||||||
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
|
import com.journeyapps.barcodescanner.ScanContract
|
||||||
|
import com.journeyapps.barcodescanner.ScanOptions
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.tasks.await
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class SetupActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private lateinit var binding: ActivitySetupBinding
|
||||||
|
private lateinit var prefs: PrefsManager
|
||||||
|
private lateinit var logAdapter: EventLogAdapter
|
||||||
|
|
||||||
|
private val requiredPermissions = buildList {
|
||||||
|
add(Manifest.permission.READ_CONTACTS)
|
||||||
|
add(Manifest.permission.WRITE_CONTACTS)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}.toTypedArray()
|
||||||
|
|
||||||
|
private val permissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { results ->
|
||||||
|
val allGranted = results.all { it.value }
|
||||||
|
if (!allGranted) {
|
||||||
|
Toast.makeText(this, "Требуются разрешения для работы", Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val qrScanLauncher = registerForActivityResult(ScanContract()) { result ->
|
||||||
|
if (result.contents != null) {
|
||||||
|
parseQrCode(result.contents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
binding = ActivitySetupBinding.inflate(layoutInflater)
|
||||||
|
setContentView(binding.root)
|
||||||
|
|
||||||
|
prefs = PrefsManager(this)
|
||||||
|
requestPermissions()
|
||||||
|
setupUI()
|
||||||
|
setupEventLog()
|
||||||
|
observeSyncProgress()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateStatusUI()
|
||||||
|
checkForUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkForUpdate() {
|
||||||
|
if (!prefs.isActivated || BuildConfig.DEBUG) return
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val updater = AppUpdater(this@SetupActivity)
|
||||||
|
val update = updater.checkForUpdate() ?: return@launch
|
||||||
|
|
||||||
|
// Show update dialog
|
||||||
|
com.google.android.material.dialog.MaterialAlertDialogBuilder(this@SetupActivity)
|
||||||
|
.setTitle("Доступно обновление")
|
||||||
|
.setMessage("Версия ${update.versionName} доступна для установки.")
|
||||||
|
.setPositiveButton("Обновить") { _, _ ->
|
||||||
|
updater.downloadAndInstall(update)
|
||||||
|
}
|
||||||
|
.setCancelable(!update.mandatory)
|
||||||
|
.apply {
|
||||||
|
if (!update.mandatory) {
|
||||||
|
setNegativeButton("Позже", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestPermissions() {
|
||||||
|
val needed = requiredPermissions.filter {
|
||||||
|
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
if (needed.isNotEmpty()) {
|
||||||
|
permissionLauncher.launch(needed.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupUI() {
|
||||||
|
binding.btnActivate.setOnClickListener { activate() }
|
||||||
|
binding.btnScanQr.setOnClickListener { scanQr() }
|
||||||
|
binding.btnSyncNow.setOnClickListener { syncNow() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setupEventLog() {
|
||||||
|
logAdapter = EventLogAdapter()
|
||||||
|
binding.recyclerLog.layoutManager = LinearLayoutManager(this)
|
||||||
|
binding.recyclerLog.adapter = logAdapter
|
||||||
|
|
||||||
|
AppDatabase.getInstance(this).eventLogDao().getRecentLive().observe(this) { events ->
|
||||||
|
if (prefs.isActivated) {
|
||||||
|
binding.cardLog.visibility = View.VISIBLE
|
||||||
|
// Refresh contacts count on every log update (catches push additions)
|
||||||
|
reloadPrefsAndUpdateUI()
|
||||||
|
}
|
||||||
|
if (events.isNullOrEmpty()) {
|
||||||
|
binding.txtLogEmpty.visibility = View.VISIBLE
|
||||||
|
binding.recyclerLog.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
binding.txtLogEmpty.visibility = View.GONE
|
||||||
|
binding.recyclerLog.visibility = View.VISIBLE
|
||||||
|
logAdapter.submitList(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSyncProgress() {
|
||||||
|
// Observe one-time sync
|
||||||
|
WorkManager.getInstance(this)
|
||||||
|
.getWorkInfosForUniqueWorkLiveData(SyncWorker.WORK_NAME_ONCE)
|
||||||
|
.observe(this) { workInfos ->
|
||||||
|
val info = workInfos?.firstOrNull() ?: return@observe
|
||||||
|
handleWorkInfo(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe periodic sync
|
||||||
|
WorkManager.getInstance(this)
|
||||||
|
.getWorkInfosForUniqueWorkLiveData(SyncWorker.WORK_NAME)
|
||||||
|
.observe(this) { workInfos ->
|
||||||
|
val info = workInfos?.firstOrNull() ?: return@observe
|
||||||
|
handleWorkInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWorkInfo(info: WorkInfo) {
|
||||||
|
when (info.state) {
|
||||||
|
WorkInfo.State.RUNNING -> {
|
||||||
|
val progress = info.progress
|
||||||
|
val phase = progress.getString(SyncWorker.KEY_PHASE) ?: ""
|
||||||
|
val current = progress.getInt(SyncWorker.KEY_CURRENT, 0)
|
||||||
|
val total = progress.getInt(SyncWorker.KEY_TOTAL, 0)
|
||||||
|
|
||||||
|
when (phase) {
|
||||||
|
SyncWorker.PHASE_DOWNLOAD -> {
|
||||||
|
if (total > 0) showPhaseProgress("Загрузка", current, total)
|
||||||
|
else showSyncRunning()
|
||||||
|
}
|
||||||
|
SyncWorker.PHASE_IMPORT -> {
|
||||||
|
if (total > 0) showPhaseProgress("Импорт в контакты", current, total)
|
||||||
|
else showSyncRunning()
|
||||||
|
}
|
||||||
|
SyncWorker.PHASE_DONE -> {
|
||||||
|
hideSyncProgress()
|
||||||
|
reloadPrefsAndUpdateUI()
|
||||||
|
}
|
||||||
|
SyncWorker.PHASE_ERROR -> {
|
||||||
|
hideSyncProgress()
|
||||||
|
binding.txtStatus.text = getString(R.string.status_error)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.error))
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
WorkInfo.State.SUCCEEDED, WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> {
|
||||||
|
hideSyncProgress()
|
||||||
|
reloadPrefsAndUpdateUI()
|
||||||
|
}
|
||||||
|
WorkInfo.State.FAILED -> {
|
||||||
|
hideSyncProgress()
|
||||||
|
binding.txtStatus.text = getString(R.string.status_error)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.error))
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadPrefsAndUpdateUI() {
|
||||||
|
// Re-read prefs since worker updated them in background
|
||||||
|
prefs = PrefsManager(this)
|
||||||
|
updateStatusUI()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSyncRunning() {
|
||||||
|
if (!prefs.isActivated) return
|
||||||
|
binding.cardStatus.visibility = View.VISIBLE
|
||||||
|
binding.progressSync.visibility = View.VISIBLE
|
||||||
|
binding.progressSync.isIndeterminate = true
|
||||||
|
binding.txtSyncProgress.visibility = View.VISIBLE
|
||||||
|
binding.txtSyncProgress.text = getString(R.string.status_syncing)
|
||||||
|
binding.txtStatus.text = getString(R.string.status_syncing)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.primary))
|
||||||
|
binding.btnSyncNow.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showPhaseProgress(phaseName: String, current: Int, total: Int) {
|
||||||
|
if (!prefs.isActivated) return
|
||||||
|
binding.cardSetup.visibility = View.GONE
|
||||||
|
binding.cardStatus.visibility = View.VISIBLE
|
||||||
|
binding.progressSync.visibility = View.VISIBLE
|
||||||
|
binding.progressSync.isIndeterminate = false
|
||||||
|
binding.progressSync.max = total
|
||||||
|
binding.progressSync.progress = current
|
||||||
|
binding.txtSyncProgress.visibility = View.VISIBLE
|
||||||
|
binding.txtSyncProgress.text = "$phaseName: $current / $total"
|
||||||
|
binding.txtStatus.text = getString(R.string.status_syncing)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.primary))
|
||||||
|
binding.txtContactsCount.text = getString(R.string.contacts_count_label, current)
|
||||||
|
binding.btnSyncNow.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideSyncProgress() {
|
||||||
|
binding.progressSync.visibility = View.GONE
|
||||||
|
binding.txtSyncProgress.visibility = View.GONE
|
||||||
|
binding.btnSyncNow.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun activate() {
|
||||||
|
val serverUrl = binding.editServerUrl.text.toString().trim()
|
||||||
|
val code = binding.editActivationCode.text.toString().trim()
|
||||||
|
|
||||||
|
if (serverUrl.isEmpty() || code.isEmpty()) {
|
||||||
|
Toast.makeText(this, R.string.error_empty_fields, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setActivating(true)
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val fcmToken = FirebaseMessaging.getInstance().token.await()
|
||||||
|
prefs.fcmToken = fcmToken
|
||||||
|
|
||||||
|
val api = ApiClient.getInstance(serverUrl)
|
||||||
|
val response = api.register(
|
||||||
|
RegisterRequest(
|
||||||
|
deviceId = prefs.deviceId,
|
||||||
|
activationCode = code,
|
||||||
|
fcmToken = fcmToken
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.success && response.deviceToken != null) {
|
||||||
|
prefs.serverUrl = serverUrl
|
||||||
|
prefs.deviceToken = response.deviceToken
|
||||||
|
prefs.pipelineId = response.pipelineId ?: 0
|
||||||
|
prefs.pipelineName = response.pipelineName ?: ""
|
||||||
|
|
||||||
|
AccountAuthenticatorService.createAccount(
|
||||||
|
this@SetupActivity,
|
||||||
|
prefs.accountName
|
||||||
|
)
|
||||||
|
|
||||||
|
SyncWorker.schedulePeriodicSync(this@SetupActivity)
|
||||||
|
HeartbeatWorker.schedule(this@SetupActivity)
|
||||||
|
SyncWorker.requestImmediateSync(this@SetupActivity)
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
this@SetupActivity,
|
||||||
|
"Активировано! Синхронизация запущена.",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
updateStatusUI()
|
||||||
|
} else {
|
||||||
|
val error = response.error ?: "Неизвестная ошибка"
|
||||||
|
Toast.makeText(
|
||||||
|
this@SetupActivity,
|
||||||
|
getString(R.string.error_activation_failed, error),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Toast.makeText(
|
||||||
|
this@SetupActivity,
|
||||||
|
getString(R.string.error_activation_failed, e.message ?: "Ошибка сети"),
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
} finally {
|
||||||
|
setActivating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanQr() {
|
||||||
|
val options = ScanOptions().apply {
|
||||||
|
setDesiredBarcodeFormats(ScanOptions.QR_CODE)
|
||||||
|
setPrompt("Наведите камеру на QR-код")
|
||||||
|
setBeepEnabled(false)
|
||||||
|
setOrientationLocked(true)
|
||||||
|
}
|
||||||
|
qrScanLauncher.launch(options)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQrCode(contents: String) {
|
||||||
|
val trimmed = contents
|
||||||
|
.replace("\uFEFF", "")
|
||||||
|
.replace("\u0000", "")
|
||||||
|
.replace(Regex("[^\\x20-\\x7E\\u0400-\\u04FF\\u0500-\\u052F{}\\[\\]:,\"'\\-.]"), "")
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
if (trimmed.startsWith("{")) {
|
||||||
|
try {
|
||||||
|
val json = org.json.JSONObject(trimmed)
|
||||||
|
val code = json.optString("code", "")
|
||||||
|
val api = json.optString("api", "")
|
||||||
|
if (code.isNotEmpty()) binding.editActivationCode.setText(code)
|
||||||
|
if (api.isNotEmpty()) binding.editServerUrl.setText(api)
|
||||||
|
return
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("bitrix24sync://")) {
|
||||||
|
try {
|
||||||
|
val uri = android.net.Uri.parse(trimmed)
|
||||||
|
val server = uri.host ?: ""
|
||||||
|
val code = uri.getQueryParameter("code") ?: ""
|
||||||
|
if (server.isNotEmpty()) binding.editServerUrl.setText("https://$server")
|
||||||
|
if (code.isNotEmpty()) binding.editActivationCode.setText(code)
|
||||||
|
return
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.editActivationCode.setText(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncNow() {
|
||||||
|
SyncWorker.requestImmediateSync(this)
|
||||||
|
binding.txtStatus.text = getString(R.string.status_syncing)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.primary))
|
||||||
|
binding.btnSyncNow.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStatusUI() {
|
||||||
|
if (prefs.isActivated) {
|
||||||
|
binding.cardSetup.visibility = View.GONE
|
||||||
|
binding.cardStatus.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
binding.txtPipeline.text = getString(R.string.pipeline_label, prefs.pipelineName)
|
||||||
|
binding.txtContactsCount.text = getString(R.string.contacts_count_label, prefs.contactsCount)
|
||||||
|
|
||||||
|
val lastSync = if (prefs.lastSyncTime > 0) {
|
||||||
|
val fmt = SimpleDateFormat("dd.MM.yyyy HH:mm", Locale.getDefault())
|
||||||
|
fmt.format(Date(prefs.lastSyncTime))
|
||||||
|
} else {
|
||||||
|
getString(R.string.never)
|
||||||
|
}
|
||||||
|
binding.txtLastSync.text = getString(R.string.last_sync_label, lastSync)
|
||||||
|
|
||||||
|
binding.txtStatus.text = getString(R.string.status_synced)
|
||||||
|
binding.txtStatus.setTextColor(ContextCompat.getColor(this, R.color.success))
|
||||||
|
} else {
|
||||||
|
binding.cardSetup.visibility = View.VISIBLE
|
||||||
|
binding.cardStatus.visibility = View.GONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setActivating(loading: Boolean) {
|
||||||
|
binding.btnActivate.isEnabled = !loading
|
||||||
|
binding.btnActivate.text = if (loading) getString(R.string.setup_activating) else getString(R.string.setup_activate)
|
||||||
|
binding.progressActivation.visibility = if (loading) View.VISIBLE else View.GONE
|
||||||
|
binding.editServerUrl.isEnabled = !loading
|
||||||
|
binding.editActivationCode.isEnabled = !loading
|
||||||
|
binding.btnScanQr.isEnabled = !loading
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package com.bitrix24.contactsync.worker
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.*
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.data.api.ApiClient
|
||||||
|
import com.bitrix24.contactsync.data.api.HeartbeatRequest
|
||||||
|
import com.bitrix24.contactsync.sync.AccountAuthenticatorService
|
||||||
|
import com.bitrix24.contactsync.sync.ContactsManager
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class HeartbeatWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "HeartbeatWorker"
|
||||||
|
const val WORK_NAME = "heartbeat_periodic"
|
||||||
|
|
||||||
|
fun schedule(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = PeriodicWorkRequestBuilder<HeartbeatWorker>(1, TimeUnit.HOURS)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "Heartbeat scheduled (1 hour interval)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val prefs = PrefsManager(applicationContext)
|
||||||
|
if (!prefs.isActivated) return Result.success()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val api = ApiClient.getInstance(prefs.serverUrl)
|
||||||
|
|
||||||
|
val account = AccountAuthenticatorService.getAccount(applicationContext)
|
||||||
|
val contactsCount = if (account != null) {
|
||||||
|
ContactsManager(applicationContext).getContactsCount(account)
|
||||||
|
} else {
|
||||||
|
prefs.contactsCount
|
||||||
|
}
|
||||||
|
|
||||||
|
val response = api.heartbeat(
|
||||||
|
HeartbeatRequest(
|
||||||
|
deviceToken = prefs.deviceToken,
|
||||||
|
fcmToken = prefs.fcmToken,
|
||||||
|
contactsCount = contactsCount,
|
||||||
|
lastSync = prefs.lastSyncTime / 1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.forceSync) {
|
||||||
|
Log.i(TAG, "Server requested force sync")
|
||||||
|
SyncWorker.requestImmediateSync(applicationContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Heartbeat failed", e)
|
||||||
|
if (runAttemptCount < 3) Result.retry() else Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
app/src/main/java/com/bitrix24/contactsync/worker/SyncWorker.kt
Normal file
277
app/src/main/java/com/bitrix24/contactsync/worker/SyncWorker.kt
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package com.bitrix24.contactsync.worker
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.*
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import com.bitrix24.contactsync.data.PrefsManager
|
||||||
|
import com.bitrix24.contactsync.data.api.ApiClient
|
||||||
|
import com.bitrix24.contactsync.data.api.ContactData
|
||||||
|
import com.bitrix24.contactsync.data.db.AppDatabase
|
||||||
|
import com.bitrix24.contactsync.data.db.EventLog
|
||||||
|
import com.bitrix24.contactsync.data.db.SyncedContact
|
||||||
|
import com.bitrix24.contactsync.sync.AccountAuthenticatorService
|
||||||
|
import com.bitrix24.contactsync.sync.ContactsManager
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class SyncWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SyncWorker"
|
||||||
|
const val WORK_NAME = "contact_sync_periodic"
|
||||||
|
const val WORK_NAME_ONCE = "contact_sync_once"
|
||||||
|
|
||||||
|
// Progress keys
|
||||||
|
const val KEY_PHASE = "phase"
|
||||||
|
const val KEY_CURRENT = "current"
|
||||||
|
const val KEY_TOTAL = "total"
|
||||||
|
|
||||||
|
const val PHASE_DOWNLOAD = "download"
|
||||||
|
const val PHASE_IMPORT = "import"
|
||||||
|
const val PHASE_DONE = "done"
|
||||||
|
const val PHASE_ERROR = "error"
|
||||||
|
|
||||||
|
private const val IMPORT_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
fun schedulePeriodicSync(context: Context) {
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = PeriodicWorkRequestBuilder<SyncWorker>(30, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 5, TimeUnit.MINUTES)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "Periodic sync scheduled (30 min interval)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestImmediateSync(context: Context) {
|
||||||
|
// Cancel periodic to avoid parallel run
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val request = OneTimeWorkRequestBuilder<SyncWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context)
|
||||||
|
.enqueueUniqueWork(
|
||||||
|
WORK_NAME_ONCE,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
request
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.i(TAG, "Immediate sync requested (periodic cancelled)")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelPeriodicSync(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Log.i(TAG, "SyncWorker started (attempt $runAttemptCount)")
|
||||||
|
val prefs = PrefsManager(applicationContext)
|
||||||
|
|
||||||
|
if (!prefs.isActivated) {
|
||||||
|
Log.w(TAG, "Device not activated, skipping")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val db = AppDatabase.getInstance(applicationContext)
|
||||||
|
|
||||||
|
// One-time dedup: remove duplicate contacts from phone book
|
||||||
|
val dedupDone = db.syncStateDao().getValue("dedup_done")
|
||||||
|
if (dedupDone == null) {
|
||||||
|
val account = AccountAuthenticatorService.getAccount(applicationContext)
|
||||||
|
if (account != null) {
|
||||||
|
val removed = ContactsManager(applicationContext).removeDuplicates(account)
|
||||||
|
if (removed > 0) {
|
||||||
|
db.eventLogDao().insert(EventLog(
|
||||||
|
type = EventLog.TYPE_SYNC,
|
||||||
|
message = "Удалено дубликатов: $removed"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.syncStateDao().setValue(com.bitrix24.contactsync.data.db.SyncState("dedup_done", "1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip download if there are already non-imported contacts waiting
|
||||||
|
val pending = db.syncedContactDao().getNotImportedCount()
|
||||||
|
if (pending > 0) {
|
||||||
|
Log.i(TAG, "Skipping download — $pending contacts waiting for import")
|
||||||
|
} else {
|
||||||
|
// Phase 1: Download from server → Room DB
|
||||||
|
downloadContacts(prefs, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Import from Room DB → phone contacts
|
||||||
|
importContacts(prefs, db)
|
||||||
|
|
||||||
|
// Log sync result
|
||||||
|
db.eventLogDao().insert(EventLog(
|
||||||
|
type = EventLog.TYPE_SYNC,
|
||||||
|
message = "Синхронизация: ${prefs.contactsCount} контактов"
|
||||||
|
))
|
||||||
|
db.eventLogDao().trimOld()
|
||||||
|
|
||||||
|
// Re-schedule periodic sync
|
||||||
|
schedulePeriodicSync(applicationContext)
|
||||||
|
|
||||||
|
setProgress(workDataOf(
|
||||||
|
KEY_PHASE to PHASE_DONE,
|
||||||
|
KEY_CURRENT to prefs.contactsCount,
|
||||||
|
KEY_TOTAL to prefs.contactsCount
|
||||||
|
))
|
||||||
|
|
||||||
|
Result.success()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Sync failed", e)
|
||||||
|
val db = AppDatabase.getInstance(applicationContext)
|
||||||
|
db.eventLogDao().insert(EventLog(
|
||||||
|
type = EventLog.TYPE_ERROR,
|
||||||
|
message = "Ошибка синхронизации: ${e.message?.take(100)}"
|
||||||
|
))
|
||||||
|
setProgress(workDataOf(KEY_PHASE to PHASE_ERROR))
|
||||||
|
if (runAttemptCount < 3) Result.retry() else Result.failure()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 1: Download all contacts from Bitrix API → local Room DB.
|
||||||
|
* Fast — only network + SQLite writes.
|
||||||
|
*/
|
||||||
|
private suspend fun downloadContacts(prefs: PrefsManager, db: AppDatabase) {
|
||||||
|
val api = ApiClient.getInstance(prefs.serverUrl)
|
||||||
|
val lastTimestamp = db.syncStateDao().getLastSyncTimestamp()
|
||||||
|
val isInitialSync = lastTimestamp == 0L
|
||||||
|
|
||||||
|
var offset = 0
|
||||||
|
var totalDownloaded = 0
|
||||||
|
var serverTotal = 0
|
||||||
|
|
||||||
|
Log.i(TAG, "Phase 1: DOWNLOAD (${if (isInitialSync) "initial" else "delta"})")
|
||||||
|
|
||||||
|
do {
|
||||||
|
val response = api.getContacts(
|
||||||
|
deviceToken = prefs.deviceToken,
|
||||||
|
since = if (isInitialSync) null else lastTimestamp,
|
||||||
|
offset = offset,
|
||||||
|
limit = 500
|
||||||
|
)
|
||||||
|
|
||||||
|
serverTotal = response.total
|
||||||
|
|
||||||
|
// Convert API contacts to Room entities
|
||||||
|
val entities = response.contacts.map { contact ->
|
||||||
|
SyncedContact(
|
||||||
|
bitrixId = contact.id,
|
||||||
|
name = contact.name,
|
||||||
|
phones = gson.toJson(contact.phones),
|
||||||
|
imported = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.syncedContactDao().upsertAll(entities)
|
||||||
|
totalDownloaded += response.contacts.size
|
||||||
|
offset += response.contacts.size
|
||||||
|
|
||||||
|
setProgress(workDataOf(
|
||||||
|
KEY_PHASE to PHASE_DOWNLOAD,
|
||||||
|
KEY_CURRENT to totalDownloaded,
|
||||||
|
KEY_TOTAL to serverTotal
|
||||||
|
))
|
||||||
|
|
||||||
|
Log.i(TAG, "Downloaded: $totalDownloaded / $serverTotal")
|
||||||
|
|
||||||
|
// Stop if server returned empty batch (delta sync edge case)
|
||||||
|
if (response.contacts.isEmpty()) break
|
||||||
|
} while (response.hasMore)
|
||||||
|
|
||||||
|
// Save timestamp immediately so next run does delta, not full
|
||||||
|
db.syncStateDao().setLastSyncTimestamp(System.currentTimeMillis() / 1000)
|
||||||
|
|
||||||
|
Log.i(TAG, "Phase 1 complete: $totalDownloaded contacts downloaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 2: Import contacts from Room DB → Android phone book.
|
||||||
|
* Processes not-yet-imported contacts in batches.
|
||||||
|
*/
|
||||||
|
private suspend fun importContacts(prefs: PrefsManager, db: AppDatabase) {
|
||||||
|
val contactsManager = ContactsManager(applicationContext)
|
||||||
|
val account = AccountAuthenticatorService.getAccount(applicationContext)
|
||||||
|
?: throw IllegalStateException("No sync account found")
|
||||||
|
|
||||||
|
val totalToImport = db.syncedContactDao().getNotImportedCount()
|
||||||
|
val alreadyImported = db.syncedContactDao().getImportedCount()
|
||||||
|
val grandTotal = totalToImport + alreadyImported
|
||||||
|
var importedNow = 0
|
||||||
|
|
||||||
|
Log.i(TAG, "Phase 2: IMPORT ($totalToImport to import, $alreadyImported already done)")
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
val batch = db.syncedContactDao().getNotImported(IMPORT_BATCH_SIZE)
|
||||||
|
if (batch.isEmpty()) break
|
||||||
|
|
||||||
|
// Convert Room entities to ContactData for ContactsManager
|
||||||
|
val contacts = batch.map { entity ->
|
||||||
|
val phones: List<String> = try {
|
||||||
|
gson.fromJson(entity.phones, Array<String>::class.java).toList()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
listOf(entity.phones)
|
||||||
|
}
|
||||||
|
ContactData(id = entity.bitrixId, name = entity.name, phones = phones)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use bulk insert only for truly initial import (nothing in phone book)
|
||||||
|
// Otherwise use upsert to avoid duplicates
|
||||||
|
if (alreadyImported == 0 && importedNow == 0) {
|
||||||
|
contactsManager.bulkInsertContacts(account, contacts)
|
||||||
|
} else {
|
||||||
|
contactsManager.upsertContacts(account, contacts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as imported in Room
|
||||||
|
val ids = batch.map { it.bitrixId }
|
||||||
|
db.syncedContactDao().markImportedBatch(ids)
|
||||||
|
|
||||||
|
importedNow += batch.size
|
||||||
|
|
||||||
|
setProgress(workDataOf(
|
||||||
|
KEY_PHASE to PHASE_IMPORT,
|
||||||
|
KEY_CURRENT to (alreadyImported + importedNow),
|
||||||
|
KEY_TOTAL to grandTotal
|
||||||
|
))
|
||||||
|
|
||||||
|
Log.i(TAG, "Imported: ${alreadyImported + importedNow} / $grandTotal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save final state — use Room count instead of heavy ContactsContract COUNT
|
||||||
|
db.syncStateDao().setLastSyncTimestamp(System.currentTimeMillis() / 1000)
|
||||||
|
val finalCount = db.syncedContactDao().getImportedCount()
|
||||||
|
db.syncStateDao().setContactsCount(finalCount)
|
||||||
|
prefs.contactsCount = finalCount
|
||||||
|
prefs.lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
Log.i(TAG, "Phase 2 complete: $importedNow imported, $finalCount total")
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#2FC6F6"
|
||||||
|
android:pathData="M54,30 C40.7,30 30,40.7 30,54 C30,67.3 40.7,78 54,78 C67.3,78 78,67.3 78,54 C78,40.7 67.3,30 54,30z M54,42 C58.4,42 62,45.6 62,50 C62,54.4 58.4,58 54,58 C49.6,58 46,54.4 46,50 C46,45.6 49.6,42 54,42z M54,73 C47,73 40.8,69.4 37.5,64 C37.6,59 48,56.2 54,56.2 C60,56.2 70.4,59 70.5,64 C67.2,69.4 61,73 54,73z" />
|
||||||
|
</vector>
|
||||||
235
app/src/main/res/layout/activity_setup.xml
Normal file
235
app/src/main/res/layout/activity_setup.xml
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
android:text="@string/app_name"
|
||||||
|
android:textColor="@color/primary"
|
||||||
|
android:textSize="24sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/setup_title"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="16sp" />
|
||||||
|
|
||||||
|
<!-- Setup Form -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardSetup"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="32dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="@string/setup_server_url"
|
||||||
|
app:boxStrokeColor="@color/primary">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editServerUrl"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textUri" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:hint="@string/setup_activation_code"
|
||||||
|
app:boxStrokeColor="@color/primary">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/editActivationCode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="textCapCharacters"
|
||||||
|
android:maxLength="20" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnScanQr"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/setup_scan_qr"
|
||||||
|
app:icon="@android:drawable/ic_menu_camera"
|
||||||
|
app:iconGravity="textStart" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnActivate"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/setup_activate"
|
||||||
|
app:backgroundTint="@color/primary" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressActivation"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Status Card (shown after activation) -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardStatus"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtPipeline"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtContactsCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtLastSync"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressSync"
|
||||||
|
style="@android:style/Widget.ProgressBar.Horizontal"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:max="100"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtSyncProgress"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSyncNow"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/sync_now" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- Event Log Card -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardLog"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/event_log_title"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/recyclerLog"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:nestedScrollingEnabled="false" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtLogEmpty"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="@string/event_log_empty"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
40
app/src/main/res/layout/item_event_log.xml
Normal file
40
app/src/main/res/layout/item_event_log.xml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:paddingVertical="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtIcon"
|
||||||
|
android:layout_width="20dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginStart="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtMessage"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/txtTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="11sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-hdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-xhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-xxhdpi/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@color/primary" />
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
12
app/src/main/res/values/colors.xml
Normal file
12
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="primary">#2FC6F6</color>
|
||||||
|
<color name="primary_dark">#0B97C4</color>
|
||||||
|
<color name="accent">#FF6D00</color>
|
||||||
|
<color name="white">#FFFFFF</color>
|
||||||
|
<color name="background">#F5F5F5</color>
|
||||||
|
<color name="text_primary">#212121</color>
|
||||||
|
<color name="text_secondary">#757575</color>
|
||||||
|
<color name="success">#4CAF50</color>
|
||||||
|
<color name="error">#F44336</color>
|
||||||
|
</resources>
|
||||||
37
app/src/main/res/values/strings.xml
Normal file
37
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Bitrix24 Контакты</string>
|
||||||
|
<string name="account_label">Bitrix24 Contacts Sync</string>
|
||||||
|
|
||||||
|
<string name="setup_title">Настройка синхронизации</string>
|
||||||
|
<string name="setup_server_url">URL сервера Bitrix24</string>
|
||||||
|
<string name="setup_server_url_hint">https://bitrix.example.ru</string>
|
||||||
|
<string name="setup_activation_code">Код активации</string>
|
||||||
|
<string name="setup_activation_code_hint">Например: HOH2024</string>
|
||||||
|
<string name="setup_scan_qr">Сканировать QR-код</string>
|
||||||
|
<string name="setup_activate">Активировать</string>
|
||||||
|
<string name="setup_activating">Активация…</string>
|
||||||
|
|
||||||
|
<string name="status_not_configured">Не настроено</string>
|
||||||
|
<string name="status_syncing">Синхронизация…</string>
|
||||||
|
<string name="status_synced">Синхронизировано</string>
|
||||||
|
<string name="status_error">Ошибка синхронизации</string>
|
||||||
|
|
||||||
|
<string name="sync_notification_channel">Синхронизация контактов</string>
|
||||||
|
<string name="sync_notification_title">Синхронизация контактов</string>
|
||||||
|
<string name="sync_notification_progress">Загружено %1$d из %2$d контактов</string>
|
||||||
|
<string name="sync_notification_done">Синхронизация завершена: %1$d контактов</string>
|
||||||
|
|
||||||
|
<string name="error_empty_fields">Заполните все поля</string>
|
||||||
|
<string name="error_invalid_url">Некорректный URL</string>
|
||||||
|
<string name="error_activation_failed">Ошибка активации: %1$s</string>
|
||||||
|
<string name="error_network">Ошибка сети</string>
|
||||||
|
|
||||||
|
<string name="pipeline_label">Воронка: %1$s</string>
|
||||||
|
<string name="contacts_count_label">Контактов: %1$d</string>
|
||||||
|
<string name="last_sync_label">Последняя синхронизация: %1$s</string>
|
||||||
|
<string name="sync_now">Синхронизировать сейчас</string>
|
||||||
|
<string name="never">никогда</string>
|
||||||
|
<string name="event_log_title">История событий</string>
|
||||||
|
<string name="event_log_empty">Нет событий</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.ContactSync" parent="Theme.Material3.Light.NoActionBar">
|
||||||
|
<item name="colorPrimary">@color/primary</item>
|
||||||
|
<item name="colorPrimaryDark">@color/primary_dark</item>
|
||||||
|
<item name="colorAccent">@color/accent</item>
|
||||||
|
<item name="android:statusBarColor">@color/primary_dark</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
6
app/src/main/res/xml/authenticator.xml
Normal file
6
app/src/main/res/xml/authenticator.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<account-authenticator xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountType="com.bitrix24.contacts.sync"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/account_label"
|
||||||
|
android:smallIcon="@mipmap/ic_launcher" />
|
||||||
7
app/src/main/res/xml/contacts.xml
Normal file
7
app/src/main/res/xml/contacts.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<ContactsAccountType xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<ContactsDataKind
|
||||||
|
android:mimeType="vnd.android.cursor.item/vnd.com.bitrix24.contactsync.profile"
|
||||||
|
android:summaryColumn="data2"
|
||||||
|
android:detailColumn="data3" />
|
||||||
|
</ContactsAccountType>
|
||||||
6
app/src/main/res/xml/file_paths.xml
Normal file
6
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<paths>
|
||||||
|
<external-files-path
|
||||||
|
name="downloads"
|
||||||
|
path="Download/" />
|
||||||
|
</paths>
|
||||||
7
app/src/main/res/xml/syncadapter.xml
Normal file
7
app/src/main/res/xml/syncadapter.xml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:accountType="com.bitrix24.contacts.sync"
|
||||||
|
android:contentAuthority="com.android.contacts"
|
||||||
|
android:isAlwaysSyncable="true"
|
||||||
|
android:supportsUploading="false"
|
||||||
|
android:userVisible="true" />
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.ksp) apply false
|
||||||
|
alias(libs.plugins.google.services) apply false
|
||||||
|
}
|
||||||
24
bump-version.sh
Normal file
24
bump-version.sh
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bump version: increments versionCode and sets versionName
|
||||||
|
# Usage: ./bump-version.sh 1.1.0
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
GRADLE_FILE="$SCRIPT_DIR/app/build.gradle.kts"
|
||||||
|
|
||||||
|
# Current version
|
||||||
|
CURRENT_CODE=$(grep 'versionCode' "$GRADLE_FILE" | head -1 | grep -o '[0-9]*')
|
||||||
|
CURRENT_NAME=$(grep 'versionName' "$GRADLE_FILE" | head -1 | grep -o '"[^"]*"' | tr -d '"')
|
||||||
|
|
||||||
|
NEW_CODE=$((CURRENT_CODE + 1))
|
||||||
|
NEW_NAME="${1:-$CURRENT_NAME}"
|
||||||
|
|
||||||
|
echo "Bumping version: $CURRENT_NAME ($CURRENT_CODE) → $NEW_NAME ($NEW_CODE)"
|
||||||
|
|
||||||
|
# Replace in build.gradle.kts
|
||||||
|
sed -i "s/versionCode = $CURRENT_CODE/versionCode = $NEW_CODE/" "$GRADLE_FILE"
|
||||||
|
sed -i "s/versionName = \"$CURRENT_NAME\"/versionName = \"$NEW_NAME\"/" "$GRADLE_FILE"
|
||||||
|
|
||||||
|
echo "Done. New version: $NEW_NAME ($NEW_CODE)"
|
||||||
|
echo "Now run: ./deploy.sh"
|
||||||
88
deploy.sh
Normal file
88
deploy.sh
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy script: build release APK and upload to Bitrix server
|
||||||
|
# Usage: ./deploy.sh [--mandatory]
|
||||||
|
#
|
||||||
|
# Requires BITRIX_WEBHOOK_URL environment variable, e.g.:
|
||||||
|
# export BITRIX_WEBHOOK_URL="https://bitrix.powerhousegym.ru/rest/1/abc123xyz/"
|
||||||
|
# Create webhook in Bitrix: Applications → Webhooks → Inbound webhook
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
export ANDROID_HOME="${ANDROID_HOME:-D:/android-sdk}"
|
||||||
|
|
||||||
|
# Check webhook URL
|
||||||
|
if [ -z "$BITRIX_WEBHOOK_URL" ]; then
|
||||||
|
echo "BITRIX_WEBHOOK_URL is not set."
|
||||||
|
echo "Create an inbound webhook in Bitrix24 (Applications → Webhooks)"
|
||||||
|
echo "Then: export BITRIX_WEBHOOK_URL=\"https://bitrix.powerhousegym.ru/rest/1/your-token/\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Read version from build.gradle.kts
|
||||||
|
VERSION_CODE=$(grep 'versionCode' app/build.gradle.kts | head -1 | grep -o '[0-9]*')
|
||||||
|
VERSION_NAME=$(grep 'versionName' app/build.gradle.kts | head -1 | grep -o '"[^"]*"' | tr -d '"')
|
||||||
|
|
||||||
|
# Server URL
|
||||||
|
SERVER_URL=$(echo "$BITRIX_WEBHOOK_URL" | grep -o 'https://[^/]*')
|
||||||
|
|
||||||
|
# Extract webhook token from URL (last path segment before trailing slash)
|
||||||
|
WEBHOOK_TOKEN=$(echo "$BITRIX_WEBHOOK_URL" | grep -oP 'rest/\d+/\K[^/]+')
|
||||||
|
WEBHOOK_USER=$(echo "$BITRIX_WEBHOOK_URL" | grep -oP 'rest/\K\d+')
|
||||||
|
|
||||||
|
MANDATORY="false"
|
||||||
|
if [ "$1" = "--mandatory" ]; then
|
||||||
|
MANDATORY="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== ContactSync Deploy ==="
|
||||||
|
echo " Version: $VERSION_NAME ($VERSION_CODE)"
|
||||||
|
echo " Server: $SERVER_URL"
|
||||||
|
echo " Webhook: user=$WEBHOOK_USER"
|
||||||
|
echo " Mandatory: $MANDATORY"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Step 1: Build release APK
|
||||||
|
echo "[1/3] Building release APK..."
|
||||||
|
./gradlew.bat assembleRelease --no-daemon -q
|
||||||
|
APK_PATH="app/build/outputs/apk/release/app-release.apk"
|
||||||
|
|
||||||
|
if [ ! -f "$APK_PATH" ]; then
|
||||||
|
echo "ERROR: APK not found at $APK_PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APK_SIZE=$(ls -lh "$APK_PATH" | awk '{print $5}')
|
||||||
|
echo " APK built: $APK_SIZE"
|
||||||
|
|
||||||
|
# Step 2: Upload to server
|
||||||
|
echo "[2/3] Uploading to $SERVER_URL..."
|
||||||
|
UPLOAD_URL="${SERVER_URL}/local/modules/contact.sync/api/upload_apk.php"
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST "$UPLOAD_URL" \
|
||||||
|
-F "apk=@$APK_PATH" \
|
||||||
|
-F "version_code=$VERSION_CODE" \
|
||||||
|
-F "version_name=$VERSION_NAME" \
|
||||||
|
-F "mandatory=$MANDATORY" \
|
||||||
|
-F "webhook_user=$WEBHOOK_USER" \
|
||||||
|
-F "webhook_token=$WEBHOOK_TOKEN")
|
||||||
|
|
||||||
|
echo " Server response: $RESPONSE"
|
||||||
|
|
||||||
|
# Check success
|
||||||
|
SUCCESS=$(echo "$RESPONSE" | grep -o '"success":true' || echo "")
|
||||||
|
if [ -z "$SUCCESS" ]; then
|
||||||
|
echo "ERROR: Upload failed!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Step 3: Verify
|
||||||
|
echo "[3/3] Verifying..."
|
||||||
|
VERIFY=$(curl -s "${SERVER_URL}/local/modules/contact.sync/api/version.php")
|
||||||
|
echo " Version on server: $VERIFY"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Deploy complete ==="
|
||||||
|
echo " All devices will see the update on next app open."
|
||||||
4
gradle.properties
Normal file
4
gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
51
gradle/libs.versions.toml
Normal file
51
gradle/libs.versions.toml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.5.2"
|
||||||
|
kotlin = "2.0.10"
|
||||||
|
coreKtx = "1.13.1"
|
||||||
|
appcompat = "1.7.0"
|
||||||
|
material = "1.12.0"
|
||||||
|
room = "2.6.1"
|
||||||
|
workmanager = "2.9.1"
|
||||||
|
retrofit = "2.11.0"
|
||||||
|
okhttp = "4.12.0"
|
||||||
|
firebase-bom = "34.11.0"
|
||||||
|
ksp = "2.0.10-1.0.24"
|
||||||
|
lifecycle = "2.8.4"
|
||||||
|
coroutines = "1.8.1"
|
||||||
|
activity = "1.9.1"
|
||||||
|
google-services = "4.4.4"
|
||||||
|
zxing = "4.3.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
|
||||||
|
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity" }
|
||||||
|
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
|
||||||
|
|
||||||
|
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
|
||||||
|
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
|
||||||
|
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
|
||||||
|
|
||||||
|
workmanager = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workmanager" }
|
||||||
|
|
||||||
|
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
|
||||||
|
retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
|
||||||
|
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
|
||||||
|
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
|
||||||
|
|
||||||
|
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" }
|
||||||
|
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
||||||
|
|
||||||
|
lifecycle-runtime = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" }
|
||||||
|
lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
|
||||||
|
|
||||||
|
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
|
zxing = { group = "com.journeyapps", name = "zxing-android-embedded", version.ref = "zxing" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||||
|
google-services = { id = "com.google.gms.google-services", version.ref = "google-services" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
73
gradlew
vendored
Normal file
73
gradlew
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Add default JVM options here.
|
||||||
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=$(basename "$0")
|
||||||
|
MAX_FD="maximum"
|
||||||
|
warn () { echo "$*"; }
|
||||||
|
die () { echo "$*"; exit 1; }
|
||||||
|
|
||||||
|
# OS specific support
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "$(uname)" in
|
||||||
|
CYGWIN* ) cygwin=true ;;
|
||||||
|
Darwin* ) darwin=true ;;
|
||||||
|
MSYS* | MINGW* ) msys=true ;;
|
||||||
|
NonStop* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
case $MAX_FD in
|
||||||
|
'' | soft) :;;
|
||||||
|
*)
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"}
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld -- "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
|
||||||
|
|
||||||
|
# Collect all arguments for the java command
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
58
gradlew.bat
vendored
Normal file
58
gradlew.bat
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%"=="" @echo off
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
|
@rem This is normally unused
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
|
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||||
|
|
||||||
|
@rem Add default JVM options here.
|
||||||
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %OS%==Windows_NT endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
|
@exit /b %ERRORLEVEL%
|
||||||
|
|
||||||
|
:fail
|
||||||
|
@exit /b 1
|
||||||
BIN
release-keystore.jks
Normal file
BIN
release-keystore.jks
Normal file
Binary file not shown.
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ContactSync"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user