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