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:
2026-04-14 10:41:16 +05:00
commit 125010bbe3
51 changed files with 2908 additions and 0 deletions

19
.gitignore vendored Normal file
View 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
View 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
View 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
View 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 *

View 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>

View 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")
}
}
}

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
)

View File

@@ -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 }
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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()
}

View File

@@ -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()
)

View File

@@ -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()
}

View 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)
}
}
}
}

View File

@@ -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)
}
}

View File

@@ -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) }
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}

View 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")
}
}

View File

@@ -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))
}
}
}

View 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
}
}

View File

@@ -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()
}
}
}

View 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")
}
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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" />

View 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>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="downloads"
path="Download/" />
</paths>

View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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

Binary file not shown.

18
settings.gradle.kts Normal file
View 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")