commit 125010bbe335ab42453359323a23aea27b44e64b Author: fitcrm Date: Tue Apr 14 10:41:16 2026 +0500 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ccc202 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..fa78b64 --- /dev/null +++ b/app/build.gradle.kts @@ -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) +} diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..2ee8707 --- /dev/null +++ b/app/google-services.json @@ -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" +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..d791eb8 --- /dev/null +++ b/app/proguard-rules.pro @@ -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 * diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..5c6eb75 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/bitrix24/contactsync/App.kt b/app/src/main/java/com/bitrix24/contactsync/App.kt new file mode 100644 index 0000000..ab721ba --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/App.kt @@ -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") + } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/PrefsManager.kt b/app/src/main/java/com/bitrix24/contactsync/data/PrefsManager.kt new file mode 100644 index 0000000..e128c90 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/PrefsManager.kt @@ -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" + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/api/ApiClient.kt b/app/src/main/java/com/bitrix24/contactsync/data/api/ApiClient.kt new file mode 100644 index 0000000..caab7b4 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/api/ApiClient.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/api/ApiService.kt b/app/src/main/java/com/bitrix24/contactsync/data/api/ApiService.kt new file mode 100644 index 0000000..6e12262 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/api/ApiService.kt @@ -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 +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/api/Models.kt b/app/src/main/java/com/bitrix24/contactsync/data/api/Models.kt new file mode 100644 index 0000000..4114872 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/api/Models.kt @@ -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, + 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 +) + +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 +) diff --git a/app/src/main/java/com/bitrix24/contactsync/data/db/AppDatabase.kt b/app/src/main/java/com/bitrix24/contactsync/data/db/AppDatabase.kt new file mode 100644 index 0000000..b696e7f --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/db/AppDatabase.kt @@ -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 } + } + } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/db/EventLog.kt b/app/src/main/java/com/bitrix24/contactsync/data/db/EventLog.kt new file mode 100644 index 0000000..b895f1f --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/db/EventLog.kt @@ -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" + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/db/EventLogDao.kt b/app/src/main/java/com/bitrix24/contactsync/data/db/EventLogDao.kt new file mode 100644 index 0000000..eb0d0cc --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/db/EventLogDao.kt @@ -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> + + @Query("DELETE FROM event_log WHERE id NOT IN (SELECT id FROM event_log ORDER BY timestamp DESC LIMIT 100)") + suspend fun trimOld() +} diff --git a/app/src/main/java/com/bitrix24/contactsync/data/db/SyncState.kt b/app/src/main/java/com/bitrix24/contactsync/data/db/SyncState.kt new file mode 100644 index 0000000..228592f --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/db/SyncState.kt @@ -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() +) diff --git a/app/src/main/java/com/bitrix24/contactsync/data/db/SyncStateDao.kt b/app/src/main/java/com/bitrix24/contactsync/data/db/SyncStateDao.kt new file mode 100644 index 0000000..f1b568d --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/data/db/SyncStateDao.kt @@ -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) + + @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 + + @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) + + @Query("SELECT * FROM synced_contacts") + suspend fun getAll(): List + + @Query("DELETE FROM synced_contacts") + suspend fun deleteAll() +} diff --git a/app/src/main/java/com/bitrix24/contactsync/fcm/FCMService.kt b/app/src/main/java/com/bitrix24/contactsync/fcm/FCMService.kt new file mode 100644 index 0000000..4e21f74 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/fcm/FCMService.kt @@ -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) { + 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 = try { + val type = object : TypeToken>() {}.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) + } + } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/receiver/BootReceiver.kt b/app/src/main/java/com/bitrix24/contactsync/receiver/BootReceiver.kt new file mode 100644 index 0000000..7540f2b --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/receiver/BootReceiver.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/sync/AccountAuthenticatorService.kt b/app/src/main/java/com/bitrix24/contactsync/sync/AccountAuthenticatorService.kt new file mode 100644 index 0000000..a4f7b5a --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/sync/AccountAuthenticatorService.kt @@ -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?, + 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? + ): Bundle { + return Bundle().apply { putBoolean(AccountManager.KEY_BOOLEAN_RESULT, false) } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/sync/ContactsManager.kt b/app/src/main/java/com/bitrix24/contactsync/sync/ContactsManager.kt new file mode 100644 index 0000000..d909c91 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/sync/ContactsManager.kt @@ -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): Int { + var count = 0 + for (chunk in contacts.chunked(BULK_BATCH_SIZE)) { + val ops = ArrayList(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): Int { + var count = 0 + for (chunk in contacts.chunked(DELTA_BATCH_SIZE)) { + val ops = ArrayList() + 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() + 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, + 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() + buildUpdateOps(rawContactId, contact, ops) + if (ops.isNotEmpty()) { + context.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + } + } + + private fun buildUpdateOps( + rawContactId: Long, + contact: ContactData, + ops: ArrayList + ) { + 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() + val seen = mutableMapOf() // 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() + 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 + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/sync/SyncAdapter.kt b/app/src/main/java/com/bitrix24/contactsync/sync/SyncAdapter.kt new file mode 100644 index 0000000..580f429 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/sync/SyncAdapter.kt @@ -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) + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/sync/SyncService.kt b/app/src/main/java/com/bitrix24/contactsync/sync/SyncService.kt new file mode 100644 index 0000000..96f4c66 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/sync/SyncService.kt @@ -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() + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/ui/AppUpdater.kt b/app/src/main/java/com/bitrix24/contactsync/ui/AppUpdater.kt new file mode 100644 index 0000000..6c244e2 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/ui/AppUpdater.kt @@ -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") + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/ui/EventLogAdapter.kt b/app/src/main/java/com/bitrix24/contactsync/ui/EventLogAdapter.kt new file mode 100644 index 0000000..6a4c2e5 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/ui/EventLogAdapter.kt @@ -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(DIFF) { + + companion object { + private val DIFF = object : DiffUtil.ItemCallback() { + 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)) + } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/ui/SetupActivity.kt b/app/src/main/java/com/bitrix24/contactsync/ui/SetupActivity.kt new file mode 100644 index 0000000..5da4026 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/ui/SetupActivity.kt @@ -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 + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/worker/HeartbeatWorker.kt b/app/src/main/java/com/bitrix24/contactsync/worker/HeartbeatWorker.kt new file mode 100644 index 0000000..d39ff34 --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/worker/HeartbeatWorker.kt @@ -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(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() + } + } +} diff --git a/app/src/main/java/com/bitrix24/contactsync/worker/SyncWorker.kt b/app/src/main/java/com/bitrix24/contactsync/worker/SyncWorker.kt new file mode 100644 index 0000000..14895fe --- /dev/null +++ b/app/src/main/java/com/bitrix24/contactsync/worker/SyncWorker.kt @@ -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(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() + .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 = try { + gson.fromJson(entity.phones, Array::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") + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..47d8907 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_setup.xml b/app/src/main/res/layout/activity_setup.xml new file mode 100644 index 0000000..4e454be --- /dev/null +++ b/app/src/main/res/layout/activity_setup.xml @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_event_log.xml b/app/src/main/res/layout/item_event_log.xml new file mode 100644 index 0000000..79c013e --- /dev/null +++ b/app/src/main/res/layout/item_event_log.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..f8ad4d6 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.xml b/app/src/main/res/mipmap-hdpi/ic_launcher.xml new file mode 100644 index 0000000..f8ad4d6 --- /dev/null +++ b/app/src/main/res/mipmap-hdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml new file mode 100644 index 0000000..f8ad4d6 --- /dev/null +++ b/app/src/main/res/mipmap-xhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml new file mode 100644 index 0000000..f8ad4d6 --- /dev/null +++ b/app/src/main/res/mipmap-xxhdpi/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..5685faf --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,12 @@ + + + #2FC6F6 + #0B97C4 + #FF6D00 + #FFFFFF + #F5F5F5 + #212121 + #757575 + #4CAF50 + #F44336 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..ad73b16 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,37 @@ + + + Bitrix24 Контакты + Bitrix24 Contacts Sync + + Настройка синхронизации + URL сервера Bitrix24 + https://bitrix.example.ru + Код активации + Например: HOH2024 + Сканировать QR-код + Активировать + Активация… + + Не настроено + Синхронизация… + Синхронизировано + Ошибка синхронизации + + Синхронизация контактов + Синхронизация контактов + Загружено %1$d из %2$d контактов + Синхронизация завершена: %1$d контактов + + Заполните все поля + Некорректный URL + Ошибка активации: %1$s + Ошибка сети + + Воронка: %1$s + Контактов: %1$d + Последняя синхронизация: %1$s + Синхронизировать сейчас + никогда + История событий + Нет событий + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..19f8363 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/app/src/main/res/xml/authenticator.xml b/app/src/main/res/xml/authenticator.xml new file mode 100644 index 0000000..83f7219 --- /dev/null +++ b/app/src/main/res/xml/authenticator.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/xml/contacts.xml b/app/src/main/res/xml/contacts.xml new file mode 100644 index 0000000..9bb3396 --- /dev/null +++ b/app/src/main/res/xml/contacts.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..1b59915 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,6 @@ + + + + diff --git a/app/src/main/res/xml/syncadapter.xml b/app/src/main/res/xml/syncadapter.xml new file mode 100644 index 0000000..1166f93 --- /dev/null +++ b/app/src/main/res/xml/syncadapter.xml @@ -0,0 +1,7 @@ + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..dc8c26a --- /dev/null +++ b/build.gradle.kts @@ -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 +} diff --git a/bump-version.sh b/bump-version.sh new file mode 100644 index 0000000..3c38e49 --- /dev/null +++ b/bump-version.sh @@ -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" diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..274151d --- /dev/null +++ b/deploy.sh @@ -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." diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..bffdbb2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e644113 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b82aa23 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..367ae6b --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93353e4 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/release-keystore.jks b/release-keystore.jks new file mode 100644 index 0000000..fdeb9f8 Binary files /dev/null and b/release-keystore.jks differ diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..770e1cd --- /dev/null +++ b/settings.gradle.kts @@ -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")