commit 26a6d09bee12f2cb5a6defb89edac111983fd9d9 Author: ioresponse Date: Wed Dec 17 02:11:13 2025 +0900 Initial commit: LDAP Contacts Android App Features: - LDAP contact management (CRUD) - Search by name, phone, company - Call/SMS integration - Android contacts sync for caller ID - Material Design 3 UI LDAP Structure: - uid-based DN for flexible cn modification - Attributes: cn, displayName, mobile, mail, o, ou, title diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e1e0e06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# Android Studio +*.iml +.idea/ +local.properties + +# APK files +*.apk +*.aab + +# Signing +*.jks +*.keystore + +# Misc +.DS_Store +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..ce6f2ba --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# LDAPContacts (외부 주소록) + +LDAP 서버와 연동하여 연락처를 관리하는 Android 앱입니다. + +## 주요 기능 + +- **LDAP 연락처 조회**: LDAP 서버에서 연락처 목록 조회 +- **검색**: 이름, 전화번호, 회사명으로 검색 +- **연락처 추가/수정/삭제**: LDAP 서버에 연락처 CRUD +- **전화/문자**: 연락처에서 바로 전화 걸기, 문자 보내기 +- **Android 연락처 동기화**: LDAP 연락처를 Android 기본 연락처에 동기화 (발신자 표시용) + +## 기술 스택 + +- **Language**: Kotlin +- **UI**: Android View Binding, Material Design 3 +- **LDAP**: UnboundID LDAP SDK +- **Async**: Kotlin Coroutines + +## LDAP 구조 + +### DN (Distinguished Name) 형식 +``` +uid={uuid},ou=ioresponse,ou=users,dc=ioresponse,dc=net +``` + +uid 기반 DN을 사용하여 cn(이름) 수정이 자유롭습니다. + +### 사용하는 LDAP 속성 + +| 속성 | 설명 | 앱 필드 | +|------|------|---------| +| uid | 고유 식별자 (UUID) | - | +| cn | 이름 | 이름 | +| displayName | 별명/닉네임 | 별명 | +| telephoneNumber | 전화번호 | 전화번호 | +| mobile | 휴대폰번호 | 전화번호 (우선) | +| mail | 이메일 | 이메일 | +| o | 회사/조직 | 회사 | +| ou | 부서 | 부서 | +| title | 직함 | 직함 | + +### 이름 표시 로직 +- `displayName`이 있으면 displayName 표시 +- 없으면 `cn` 표시 + +## 주요 소스 파일 + +``` +app/src/main/java/net/ioresponse/ldapcontacts/ +├── MainActivity.kt # 메인 화면 (연락처 목록) +├── ContactDetailActivity.kt # 연락처 상세 화면 +├── EditContactActivity.kt # 연락처 추가/수정 화면 +├── SettingsActivity.kt # LDAP 서버 설정 +├── Contact.kt # 연락처 데이터 모델 +├── ContactAdapter.kt # RecyclerView 어댑터 +├── LdapManager.kt # LDAP 통신 관리 +└── ContactSyncManager.kt # Android 연락처 동기화 +``` + +## LdapManager 주요 함수 + +```kotlin +// 전체 연락처 조회 +suspend fun getContacts(): Result> + +// 연락처 검색 (이름, 전화번호, 회사명) +suspend fun searchContacts(query: String): Result> + +// DN으로 연락처 조회 +suspend fun getContactByDn(dn: String): Contact? + +// 연락처 추가 +suspend fun addContact(contact: Contact): Result + +// 연락처 수정 +suspend fun updateContact(contact: Contact): Result + +// 연락처 삭제 +suspend fun deleteContact(contact: Contact): Result +``` + +## 연락처 동기화 로직 + +`ContactSyncManager.kt`에서 Android 기본 연락처에 동기화: + +1. **기존 연락처 전체 삭제** (CALLER_IS_SYNCADAPTER 사용) +2. **휴지통 비우기 시도** +3. **LDAP 연락처 추가** + +> 삼성 기기에서는 휴지통 자동 비우기가 동작하지 않아 사용자가 수동으로 비워야 합니다. + +## 빌드 방법 + +```bash +# Debug APK 빌드 +./gradlew assembleDebug + +# APK 위치 +app/build/outputs/apk/debug/app-debug.apk +``` + +## 설정 + +앱 실행 후 설정 화면에서 LDAP 서버 정보 입력: + +- **서버**: LDAP 서버 주소 +- **포트**: LDAP 포트 (기본 389) +- **Bind DN**: 인증용 DN (예: cn=Directory Manager) +- **비밀번호**: Bind DN 비밀번호 +- **Base DN**: 검색 기준 DN + +## 권한 + +- `INTERNET`: LDAP 서버 연결 +- `READ_CONTACTS`, `WRITE_CONTACTS`: Android 연락처 동기화 +- `CALL_PHONE`: 전화 걸기 + +## 라이선스 + +Private - ioresponse.net diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..56b8c6c --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'net.ioresponse.ldapcontacts' + compileSdk 34 + + defaultConfig { + applicationId "net.ioresponse.ldapcontacts" + minSdk 26 + targetSdk 34 + versionCode 1 + versionName "1.0" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + viewBinding true + } + + packagingOptions { + resources { + excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt'] + } + } +} + +dependencies { + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.recyclerview:recyclerview:1.3.2' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + + // LDAP + implementation 'com.unboundid:unboundid-ldapsdk:6.0.11' + + // Coroutines + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + + // Lifecycle + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.7.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.7.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..fc5c3fc --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,3 @@ +# UnboundID LDAP SDK +-keep class com.unboundid.** { *; } +-dontwarn com.unboundid.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..70da78a --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/Contact.kt b/app/src/main/java/net/ioresponse/ldapcontacts/Contact.kt new file mode 100644 index 0000000..92e7db8 --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/Contact.kt @@ -0,0 +1,44 @@ +package net.ioresponse.ldapcontacts + +import java.io.Serializable + +data class Contact( + var dn: String = "", + var cn: String = "", + var displayName: String = "", + var telephoneNumber: String = "", + var mobile: String = "", + var mail: String = "", + var organization: String = "", + var department: String = "", + var title: String = "" +) : Serializable { + + val name: String + get() = displayName.ifEmpty { cn } + + val phone: String + get() = mobile.ifEmpty { telephoneNumber } + + val initials: String + get() { + val n = name + return if (n.isNotEmpty()) n.first().toString() else "?" + } + + companion object { + fun fromLdapEntry(dn: String, attributes: Map): Contact { + return Contact( + dn = dn, + cn = attributes["cn"] ?: "", + displayName = attributes["displayName"] ?: "", + telephoneNumber = attributes["telephoneNumber"] ?: "", + mobile = attributes["mobile"] ?: "", + mail = attributes["mail"] ?: "", + organization = attributes["o"] ?: "", + department = attributes["ou"] ?: attributes["departmentNumber"] ?: "", + title = attributes["title"] ?: "" + ) + } + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/ContactAdapter.kt b/app/src/main/java/net/ioresponse/ldapcontacts/ContactAdapter.kt new file mode 100644 index 0000000..9fa2502 --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/ContactAdapter.kt @@ -0,0 +1,97 @@ +package net.ioresponse.ldapcontacts + +import android.graphics.Color +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView + +class ContactAdapter( + private var contacts: List, + private val onItemClick: (Contact) -> Unit, + private val onCallClick: (Contact) -> Unit +) : RecyclerView.Adapter() { + + private val colors = listOf( + Color.parseColor("#F44336"), + Color.parseColor("#E91E63"), + Color.parseColor("#9C27B0"), + Color.parseColor("#673AB7"), + Color.parseColor("#3F51B5"), + Color.parseColor("#2196F3"), + Color.parseColor("#03A9F4"), + Color.parseColor("#00BCD4"), + Color.parseColor("#009688"), + Color.parseColor("#4CAF50"), + Color.parseColor("#8BC34A"), + Color.parseColor("#FF9800"), + Color.parseColor("#FF5722") + ) + + class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val initialsText: TextView = view.findViewById(R.id.initialsText) + val nameText: TextView = view.findViewById(R.id.nameText) + val phoneText: TextView = view.findViewById(R.id.phoneText) + val orgText: TextView = view.findViewById(R.id.orgText) + val callButton: ImageButton = view.findViewById(R.id.callButton) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.item_contact, parent, false) + return ViewHolder(view) + } + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val contact = contacts[position] + + holder.initialsText.text = contact.initials + holder.initialsText.background.setTint(colors[position % colors.size]) + + // 이름 + 직급 표시 + val nameWithTitle = buildString { + append(contact.name) + if (contact.title.isNotEmpty() && contact.title != "-") { + append(" ") + append(contact.title) + } + } + holder.nameText.text = nameWithTitle + holder.phoneText.text = contact.phone.ifEmpty { "-" } + + // 회사 - 부서 형식으로 표시 + val orgDept = buildString { + if (contact.organization.isNotEmpty() && contact.organization != "-") { + append(contact.organization) + } + if (contact.department.isNotEmpty() && contact.department != "-") { + if (isNotEmpty()) append(" - ") + append(contact.department) + } + } + if (orgDept.isNotEmpty()) { + holder.orgText.visibility = View.VISIBLE + holder.orgText.text = orgDept + } else { + holder.orgText.visibility = View.GONE + } + + holder.itemView.setOnClickListener { onItemClick(contact) } + + if (contact.phone.isNotEmpty()) { + holder.callButton.visibility = View.VISIBLE + holder.callButton.setOnClickListener { onCallClick(contact) } + } else { + holder.callButton.visibility = View.GONE + } + } + + override fun getItemCount() = contacts.size + + fun updateContacts(newContacts: List) { + contacts = newContacts + notifyDataSetChanged() + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/ContactDetailActivity.kt b/app/src/main/java/net/ioresponse/ldapcontacts/ContactDetailActivity.kt new file mode 100644 index 0000000..9b9f1f4 --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/ContactDetailActivity.kt @@ -0,0 +1,140 @@ +package net.ioresponse.ldapcontacts + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import net.ioresponse.ldapcontacts.databinding.ActivityContactDetailBinding + +class ContactDetailActivity : AppCompatActivity() { + + private lateinit var binding: ActivityContactDetailBinding + private lateinit var ldapManager: LdapManager + private var contact: Contact? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityContactDetailBinding.inflate(layoutInflater) + setContentView(binding.root) + + ldapManager = LdapManager(this) + + @Suppress("DEPRECATION") + contact = intent.getSerializableExtra("contact") as? Contact + + setupToolbar() + displayContact() + setupButtons() + } + + private fun setupToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.setNavigationOnClickListener { finish() } + } + + private fun displayContact() { + contact?.let { c -> + supportActionBar?.title = c.name + + binding.initialsText.text = c.initials + binding.nameText.text = c.name + binding.phoneText.text = c.phone.ifEmpty { "-" } + binding.emailText.text = c.mail.ifEmpty { "-" } + binding.orgText.text = c.organization.ifEmpty { "-" } + binding.deptText.text = c.department.ifEmpty { "-" } + binding.titleText.text = c.title.ifEmpty { "-" } + } + } + + private fun setupButtons() { + binding.callButton.setOnClickListener { + contact?.phone?.let { phone -> + if (phone.isNotEmpty()) makeCall(phone) + } + } + + binding.messageButton.setOnClickListener { + contact?.phone?.let { phone -> + if (phone.isNotEmpty()) { + val intent = Intent(Intent.ACTION_SENDTO) + intent.data = Uri.parse("smsto:$phone") + startActivity(intent) + } + } + } + + binding.editButton.setOnClickListener { + val intent = Intent(this, EditContactActivity::class.java) + intent.putExtra("contact", contact) + startActivity(intent) + } + + binding.deleteButton.setOnClickListener { + showDeleteConfirmation() + } + } + + private fun showDeleteConfirmation() { + AlertDialog.Builder(this) + .setTitle(R.string.delete_contact) + .setMessage(R.string.confirm_delete) + .setPositiveButton(R.string.delete) { _, _ -> + deleteContact() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun deleteContact() { + contact?.let { c -> + lifecycleScope.launch { + ldapManager.deleteContact(c).fold( + onSuccess = { + Toast.makeText(this@ContactDetailActivity, "삭제되었습니다", Toast.LENGTH_SHORT).show() + finish() + }, + onFailure = { error -> + Toast.makeText(this@ContactDetailActivity, "삭제 실패: ${error.message}", Toast.LENGTH_SHORT).show() + } + ) + } + } + } + + private fun makeCall(phoneNumber: String) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CALL_PHONE), 1) + return + } + val intent = Intent(Intent.ACTION_CALL) + intent.data = Uri.parse("tel:$phoneNumber") + startActivity(intent) + } + + override fun onResume() { + super.onResume() + // Reload contact in case it was edited + reloadContact() + } + + private fun reloadContact() { + contact?.let { c -> + lifecycleScope.launch { + ldapManager.getContactByDn(c.dn)?.let { updated -> + contact = updated + displayContact() + } + } + } + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/ContactSyncManager.kt b/app/src/main/java/net/ioresponse/ldapcontacts/ContactSyncManager.kt new file mode 100644 index 0000000..974f8b8 --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/ContactSyncManager.kt @@ -0,0 +1,174 @@ +package net.ioresponse.ldapcontacts + +import android.content.ContentProviderOperation +import android.content.ContentResolver +import android.content.Context +import android.provider.ContactsContract +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +class ContactSyncManager(private val context: Context) { + + suspend fun syncContacts(ldapContacts: List): Result = withContext(Dispatchers.IO) { + try { + val contentResolver = context.contentResolver + + // 1. 전체 연락처 삭제 + val deletedCount = deleteAllContacts(contentResolver) + + // 2. 휴지통 비우기 + emptyTrash(contentResolver) + + // 3. LDAP 연락처 추가 + var addedCount = 0 + for (contact in ldapContacts) { + if (addContact(contentResolver, contact)) { + addedCount++ + } + } + + Result.success(SyncResult(addedCount, deletedCount)) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun deleteAllContacts(contentResolver: ContentResolver): Int { + var totalDeleted = 0 + + // 1. 모든 RawContact ID 조회 + val rawContactIds = mutableListOf() + contentResolver.query( + ContactsContract.RawContacts.CONTENT_URI, + arrayOf(ContactsContract.RawContacts._ID), + null, null, null + )?.use { cursor -> + while (cursor.moveToNext()) { + rawContactIds.add(cursor.getLong(0)) + } + } + + // 2. 각 RawContact를 CALLER_IS_SYNCADAPTER로 개별 삭제 (영구 삭제) + for (rawContactId in rawContactIds) { + try { + val uri = ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendPath(rawContactId.toString()) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build() + val deleted = contentResolver.delete(uri, null, null) + totalDeleted += deleted + } catch (e: Exception) { + e.printStackTrace() + } + } + + return totalDeleted + } + + private fun emptyTrash(contentResolver: ContentResolver) { + // 휴지통에 있는 연락처 (DELETED=1) 영구 삭제 + try { + val deletedIds = mutableListOf() + contentResolver.query( + ContactsContract.RawContacts.CONTENT_URI, + arrayOf(ContactsContract.RawContacts._ID), + "${ContactsContract.RawContacts.DELETED} = 1", + null, null + )?.use { cursor -> + while (cursor.moveToNext()) { + deletedIds.add(cursor.getLong(0)) + } + } + + for (id in deletedIds) { + val uri = ContactsContract.RawContacts.CONTENT_URI.buildUpon() + .appendPath(id.toString()) + .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true") + .build() + contentResolver.delete(uri, null, null) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + + private fun addContact(contentResolver: ContentResolver, contact: Contact): Boolean { + try { + val ops = ArrayList() + + // RawContact 생성 + ops.add( + ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI) + .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null) + .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null) + .build() + ) + + // 이름 + val displayName = buildString { + append(contact.name) + if (contact.title.isNotEmpty() && contact.title != "-") { + append(" ") + append(contact.title) + } + } + ops.add( + ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName) + .build() + ) + + // 전화번호 + if (contact.phone.isNotEmpty()) { + ops.add( + ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, contact.phone) + .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE) + .build() + ) + } + + // 이메일 + if (contact.mail.isNotEmpty() && contact.mail != "-") { + ops.add( + ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, contact.mail) + .withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_WORK) + .build() + ) + } + + // 회사/부서/직급 + val company = if (contact.organization.isNotEmpty() && contact.organization != "-") contact.organization else "" + val department = if (contact.department.isNotEmpty() && contact.department != "-") contact.department else "" + val title = if (contact.title.isNotEmpty() && contact.title != "-") contact.title else "" + + if (company.isNotEmpty() || department.isNotEmpty() || title.isNotEmpty()) { + ops.add( + ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI) + .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0) + .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Organization.CONTENT_ITEM_TYPE) + .withValue(ContactsContract.CommonDataKinds.Organization.COMPANY, company) + .withValue(ContactsContract.CommonDataKinds.Organization.DEPARTMENT, department) + .withValue(ContactsContract.CommonDataKinds.Organization.TITLE, title) + .withValue(ContactsContract.CommonDataKinds.Organization.TYPE, ContactsContract.CommonDataKinds.Organization.TYPE_WORK) + .build() + ) + } + + contentResolver.applyBatch(ContactsContract.AUTHORITY, ops) + return true + } catch (e: Exception) { + e.printStackTrace() + return false + } + } + + data class SyncResult(val added: Int, val deleted: Int) +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/EditContactActivity.kt b/app/src/main/java/net/ioresponse/ldapcontacts/EditContactActivity.kt new file mode 100644 index 0000000..fb898e5 --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/EditContactActivity.kt @@ -0,0 +1,131 @@ +package net.ioresponse.ldapcontacts + +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import net.ioresponse.ldapcontacts.databinding.ActivityEditContactBinding + +class EditContactActivity : AppCompatActivity() { + + private lateinit var binding: ActivityEditContactBinding + private lateinit var ldapManager: LdapManager + private var existingContact: Contact? = null + private var isEditMode = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityEditContactBinding.inflate(layoutInflater) + setContentView(binding.root) + + ldapManager = LdapManager(this) + + @Suppress("DEPRECATION") + existingContact = intent.getSerializableExtra("contact") as? Contact + isEditMode = existingContact != null + + setupToolbar() + populateFields() + setupSaveButton() + } + + private fun setupToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = if (isEditMode) getString(R.string.edit_contact) else getString(R.string.add_contact) + binding.toolbar.setNavigationOnClickListener { finish() } + } + + private fun populateFields() { + existingContact?.let { contact -> + binding.nameEditText.setText(contact.cn) + binding.nicknameEditText.setText(contact.displayName) + binding.phoneEditText.setText(contact.phone) + binding.emailEditText.setText(contact.mail) + binding.companyEditText.setText(contact.organization) + binding.departmentEditText.setText(contact.department) + binding.titleEditText.setText(contact.title) + } + } + + private fun setupSaveButton() { + binding.saveButton.setOnClickListener { + val name = binding.nameEditText.text?.toString()?.trim() ?: "" + val nickname = binding.nicknameEditText.text?.toString()?.trim() ?: "" + val phone = binding.phoneEditText.text?.toString()?.trim() ?: "" + val email = binding.emailEditText.text?.toString()?.trim() ?: "" + val company = binding.companyEditText.text?.toString()?.trim() ?: "" + val department = binding.departmentEditText.text?.toString()?.trim() ?: "" + val title = binding.titleEditText.text?.toString()?.trim() ?: "" + + if (name.isEmpty()) { + binding.nameEditText.error = "이름을 입력하세요" + return@setOnClickListener + } + + val contact = if (isEditMode) { + existingContact!!.copy( + cn = name, + displayName = nickname, + mobile = phone, + telephoneNumber = phone, + mail = email, + organization = company, + department = department, + title = title + ) + } else { + Contact( + cn = name, + displayName = nickname, + mobile = phone, + telephoneNumber = phone, + mail = email, + organization = company, + department = department, + title = title + ) + } + + saveContact(contact) + } + } + + private fun saveContact(contact: Contact) { + showLoading(true) + + lifecycleScope.launch { + val result = if (isEditMode) { + ldapManager.updateContact(contact) + } else { + ldapManager.addContact(contact) + } + + result.fold( + onSuccess = { + Toast.makeText( + this@EditContactActivity, + if (isEditMode) "수정되었습니다" else "추가되었습니다", + Toast.LENGTH_SHORT + ).show() + finish() + }, + onFailure = { error -> + showLoading(false) + Toast.makeText( + this@EditContactActivity, + "저장 실패: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + ) + } + } + + private fun showLoading(show: Boolean) { + binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE + binding.saveButton.isEnabled = !show + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/LdapManager.kt b/app/src/main/java/net/ioresponse/ldapcontacts/LdapManager.kt new file mode 100644 index 0000000..e5e22cb --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/LdapManager.kt @@ -0,0 +1,263 @@ +package net.ioresponse.ldapcontacts + +import android.content.Context +import com.unboundid.ldap.sdk.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID +import java.io.ByteArrayOutputStream + +class LdapManager(private val context: Context) { + + // Quoted-Printable 디코딩 + private fun decodeQuotedPrintable(input: String): String { + if (!input.contains("=")) return input + + try { + // soft line break 제거 및 끝의 불완전한 = 제거 + var cleaned = input.replace("=\r\n", "").replace("=\n", "").replace("=\r", "") + // 끝에 = 또는 =X 형태로 끝나면 제거 (불완전한 인코딩) + cleaned = cleaned.replace(Regex("=[0-9A-Fa-f]?$"), "") + + val baos = ByteArrayOutputStream() + var i = 0 + while (i < cleaned.length) { + val c = cleaned[i] + if (c == '=' && i + 2 < cleaned.length) { + val hex = cleaned.substring(i + 1, i + 3) + if (hex.matches(Regex("[0-9A-Fa-f]{2}"))) { + baos.write(hex.toInt(16)) + i += 3 + continue + } + } + baos.write(c.code) + i++ + } + val result = String(baos.toByteArray(), Charsets.UTF_8) + // 세미콜론 뒤의 회사명만 추출 (FINNQ;부서명 형태) + return if (result.contains(";")) { + result.substringAfter(";") + } else { + result + } + } catch (e: Exception) { + return input + } + } + + private val prefs = context.getSharedPreferences("ldap_settings", Context.MODE_PRIVATE) + + var server: String + get() = prefs.getString("server", "49.247.139.155") ?: "49.247.139.155" + set(value) = prefs.edit().putString("server", value).apply() + + var port: Int + get() = prefs.getInt("port", 389) + set(value) = prefs.edit().putInt("port", value).apply() + + var bindDn: String + get() = prefs.getString("bindDn", "cn=Directory Manager") ?: "cn=Directory Manager" + set(value) = prefs.edit().putString("bindDn", value).apply() + + var password: String + get() = prefs.getString("password", "Qkdrnwoddl11@") ?: "" + set(value) = prefs.edit().putString("password", value).apply() + + var baseDn: String + get() = prefs.getString("baseDn", "ou=ioresponse,ou=users,dc=ioresponse,dc=net") ?: "" + set(value) = prefs.edit().putString("baseDn", value).apply() + + private fun getConnection(): LDAPConnection { + val connection = LDAPConnection() + connection.connect(server, port, 10000) + connection.bind(bindDn, password) + return connection + } + + suspend fun getContacts(): Result> = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + val contacts = mutableListOf() + + val searchRequest = SearchRequest( + baseDn, + SearchScope.SUB, + "(objectClass=inetOrgPerson)", + "cn", "displayName", "telephoneNumber", "mobile", "mail", "o", "ou", "departmentNumber", "title" + ) + + val result = connection.search(searchRequest) + + for (entry in result.searchEntries) { + val attrs = mutableMapOf() + for (attr in entry.attributes) { + attrs[attr.name] = decodeQuotedPrintable(attr.value ?: "") + } + contacts.add(Contact.fromLdapEntry(entry.dn, attrs)) + } + + connection.close() + + // Sort by Korean name (가나다 순) + contacts.sortBy { it.name } + + Result.success(contacts) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun searchContacts(query: String): Result> = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + val contacts = mutableListOf() + + val filter = Filter.createORFilter( + Filter.createSubstringFilter("cn", query, null, null), + Filter.createSubstringFilter("displayName", query, null, null), + Filter.createSubstringFilter("telephoneNumber", null, arrayOf(query), null), + Filter.createSubstringFilter("mobile", null, arrayOf(query), null), + Filter.createSubstringFilter("o", query, null, null) + ) + + val searchRequest = SearchRequest( + baseDn, + SearchScope.SUB, + filter, + "cn", "displayName", "telephoneNumber", "mobile", "mail", "o", "ou", "departmentNumber", "title" + ) + + val result = connection.search(searchRequest) + + for (entry in result.searchEntries) { + val attrs = mutableMapOf() + for (attr in entry.attributes) { + attrs[attr.name] = decodeQuotedPrintable(attr.value ?: "") + } + contacts.add(Contact.fromLdapEntry(entry.dn, attrs)) + } + + connection.close() + contacts.sortBy { it.name } + + Result.success(contacts) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun getContactByDn(dn: String): Contact? = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + val entry = connection.getEntry(dn, "cn", "displayName", "telephoneNumber", "mobile", "mail", "o", "ou", "departmentNumber", "title") + connection.close() + + entry?.let { + val attrs = mutableMapOf() + for (attr in it.attributes) { + attrs[attr.name] = decodeQuotedPrintable(attr.value ?: "") + } + Contact.fromLdapEntry(it.dn, attrs) + } + } catch (e: Exception) { + null + } + } + + suspend fun addContact(contact: Contact): Result = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + + val uid = UUID.randomUUID().toString().substring(0, 8) + val realName = contact.cn.ifEmpty { contact.displayName } + val dn = "uid=$uid,$baseDn" + + val attributes = mutableListOf( + Attribute("objectClass", "top", "person", "organizationalPerson", "inetOrgPerson"), + Attribute("uid", uid), + Attribute("cn", realName), + Attribute("sn", realName.firstOrNull()?.toString() ?: "Unknown"), + Attribute("displayName", contact.displayName.ifEmpty { realName }) + ) + + if (contact.telephoneNumber.isNotEmpty()) { + attributes.add(Attribute("telephoneNumber", contact.telephoneNumber)) + } + if (contact.mobile.isNotEmpty()) { + attributes.add(Attribute("mobile", contact.mobile)) + } + if (contact.mail.isNotEmpty()) { + attributes.add(Attribute("mail", contact.mail)) + } + if (contact.organization.isNotEmpty()) { + attributes.add(Attribute("o", contact.organization)) + } + if (contact.department.isNotEmpty()) { + attributes.add(Attribute("ou", contact.department)) + } + if (contact.title.isNotEmpty()) { + attributes.add(Attribute("title", contact.title)) + } + + connection.add(dn, attributes) + connection.close() + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun updateContact(contact: Contact): Result = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + + val modifications = mutableListOf() + + // uid 기반 DN이므로 cn 수정이 자유롭게 가능 + if (contact.cn.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "cn", contact.cn)) + modifications.add(Modification(ModificationType.REPLACE, "sn", contact.cn.firstOrNull()?.toString() ?: "Unknown")) + } + modifications.add(Modification(ModificationType.REPLACE, "displayName", contact.displayName.ifEmpty { contact.cn })) + + if (contact.telephoneNumber.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "telephoneNumber", contact.telephoneNumber)) + } + if (contact.mobile.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "mobile", contact.mobile)) + } + if (contact.mail.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "mail", contact.mail)) + } + if (contact.organization.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "o", contact.organization)) + } + if (contact.department.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "ou", contact.department)) + } + if (contact.title.isNotEmpty()) { + modifications.add(Modification(ModificationType.REPLACE, "title", contact.title)) + } + + connection.modify(contact.dn, modifications) + connection.close() + + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun deleteContact(contact: Contact): Result = withContext(Dispatchers.IO) { + try { + val connection = getConnection() + connection.delete(contact.dn) + connection.close() + Result.success(Unit) + } catch (e: Exception) { + Result.failure(e) + } + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/MainActivity.kt b/app/src/main/java/net/ioresponse/ldapcontacts/MainActivity.kt new file mode 100644 index 0000000..e42357c --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/MainActivity.kt @@ -0,0 +1,241 @@ +package net.ioresponse.ldapcontacts + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.LinearLayoutManager +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import net.ioresponse.ldapcontacts.databinding.ActivityMainBinding + +class MainActivity : AppCompatActivity() { + + private lateinit var binding: ActivityMainBinding + private lateinit var ldapManager: LdapManager + private lateinit var syncManager: ContactSyncManager + private lateinit var adapter: ContactAdapter + private var allContacts = listOf() + private var searchJob: Job? = null + + companion object { + private const val PERMISSION_REQUEST_CONTACTS = 100 + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + + setSupportActionBar(binding.toolbar) + + ldapManager = LdapManager(this) + syncManager = ContactSyncManager(this) + + setupRecyclerView() + setupSearch() + setupSwipeRefresh() + setupFab() + + loadContacts() + } + + private fun setupRecyclerView() { + adapter = ContactAdapter( + emptyList(), + onItemClick = { contact -> + val intent = Intent(this, ContactDetailActivity::class.java) + intent.putExtra("contact", contact) + startActivity(intent) + }, + onCallClick = { contact -> + makeCall(contact.phone) + } + ) + binding.contactsRecyclerView.layoutManager = LinearLayoutManager(this) + binding.contactsRecyclerView.adapter = adapter + } + + private fun setupSearch() { + binding.searchEditText.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable?) { + searchJob?.cancel() + searchJob = lifecycleScope.launch { + delay(300) + val query = s?.toString()?.trim() ?: "" + if (query.isEmpty()) { + adapter.updateContacts(allContacts) + } else { + searchContacts(query) + } + } + } + }) + } + + private fun setupSwipeRefresh() { + binding.swipeRefresh.setOnRefreshListener { + binding.searchEditText.text?.clear() + loadContacts() + } + } + + private fun setupFab() { + binding.fabAdd.setOnClickListener { + val intent = Intent(this, EditContactActivity::class.java) + startActivity(intent) + } + } + + private fun loadContacts() { + showLoading(true) + lifecycleScope.launch { + ldapManager.getContacts().fold( + onSuccess = { contacts -> + allContacts = contacts + adapter.updateContacts(contacts) + showLoading(false) + showEmpty(contacts.isEmpty()) + }, + onFailure = { error -> + showLoading(false) + showEmpty(true) + Toast.makeText(this@MainActivity, "연결 실패: ${error.message}", Toast.LENGTH_LONG).show() + } + ) + } + } + + private fun searchContacts(query: String) { + lifecycleScope.launch { + ldapManager.searchContacts(query).fold( + onSuccess = { contacts -> + adapter.updateContacts(contacts) + showEmpty(contacts.isEmpty()) + }, + onFailure = { error -> + Toast.makeText(this@MainActivity, "검색 실패: ${error.message}", Toast.LENGTH_SHORT).show() + } + ) + } + } + + private fun makeCall(phoneNumber: String) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CALL_PHONE) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(this, arrayOf(Manifest.permission.CALL_PHONE), 1) + return + } + val intent = Intent(Intent.ACTION_CALL) + intent.data = Uri.parse("tel:$phoneNumber") + startActivity(intent) + } + + private fun showLoading(show: Boolean) { + binding.swipeRefresh.isRefreshing = false + binding.progressBar.visibility = if (show) View.VISIBLE else View.GONE + binding.contactsRecyclerView.visibility = if (show) View.GONE else View.VISIBLE + } + + private fun showEmpty(show: Boolean) { + binding.emptyText.visibility = if (show) View.VISIBLE else View.GONE + binding.contactsRecyclerView.visibility = if (show) View.GONE else View.VISIBLE + } + + override fun onResume() { + super.onResume() + loadContacts() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.main_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + return when (item.itemId) { + R.id.action_sync -> { + requestContactsPermissionAndSync() + true + } + R.id.action_settings -> { + startActivity(Intent(this, SettingsActivity::class.java)) + true + } + else -> super.onOptionsItemSelected(item) + } + } + + private fun requestContactsPermissionAndSync() { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.READ_CONTACTS, Manifest.permission.WRITE_CONTACTS), + PERMISSION_REQUEST_CONTACTS + ) + } else { + syncToAndroidContacts() + } + } + + private fun syncToAndroidContacts() { + if (allContacts.isEmpty()) { + Toast.makeText(this, "동기화할 연락처가 없습니다", Toast.LENGTH_SHORT).show() + return + } + + showLoading(true) + lifecycleScope.launch { + syncManager.syncContacts(allContacts).fold( + onSuccess = { result -> + showLoading(false) + Toast.makeText( + this@MainActivity, + "${result.added}개 동기화 완료! 휴지통을 비워주세요.", + Toast.LENGTH_LONG + ).show() + }, + onFailure = { error -> + showLoading(false) + Toast.makeText( + this@MainActivity, + "동기화 실패: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + ) + } + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + when (requestCode) { + PERMISSION_REQUEST_CONTACTS -> { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + syncToAndroidContacts() + } else { + Toast.makeText(this, "연락처 권한이 필요합니다", Toast.LENGTH_SHORT).show() + } + } + } + } +} diff --git a/app/src/main/java/net/ioresponse/ldapcontacts/SettingsActivity.kt b/app/src/main/java/net/ioresponse/ldapcontacts/SettingsActivity.kt new file mode 100644 index 0000000..a08ae4c --- /dev/null +++ b/app/src/main/java/net/ioresponse/ldapcontacts/SettingsActivity.kt @@ -0,0 +1,89 @@ +package net.ioresponse.ldapcontacts + +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch +import net.ioresponse.ldapcontacts.databinding.ActivitySettingsBinding + +class SettingsActivity : AppCompatActivity() { + + private lateinit var binding: ActivitySettingsBinding + private lateinit var ldapManager: LdapManager + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivitySettingsBinding.inflate(layoutInflater) + setContentView(binding.root) + + ldapManager = LdapManager(this) + + setupToolbar() + loadSettings() + setupButtons() + } + + private fun setupToolbar() { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + binding.toolbar.setNavigationOnClickListener { finish() } + } + + private fun loadSettings() { + binding.serverEditText.setText(ldapManager.server) + binding.portEditText.setText(ldapManager.port.toString()) + binding.bindDnEditText.setText(ldapManager.bindDn) + binding.passwordEditText.setText(ldapManager.password) + binding.baseDnEditText.setText(ldapManager.baseDn) + } + + private fun setupButtons() { + binding.saveButton.setOnClickListener { + saveSettings() + } + + binding.testButton.setOnClickListener { + testConnection() + } + } + + private fun saveSettings() { + ldapManager.server = binding.serverEditText.text?.toString() ?: "" + ldapManager.port = binding.portEditText.text?.toString()?.toIntOrNull() ?: 389 + ldapManager.bindDn = binding.bindDnEditText.text?.toString() ?: "" + ldapManager.password = binding.passwordEditText.text?.toString() ?: "" + ldapManager.baseDn = binding.baseDnEditText.text?.toString() ?: "" + + Toast.makeText(this, "설정이 저장되었습니다", Toast.LENGTH_SHORT).show() + finish() + } + + private fun testConnection() { + // Temporarily save settings for test + ldapManager.server = binding.serverEditText.text?.toString() ?: "" + ldapManager.port = binding.portEditText.text?.toString()?.toIntOrNull() ?: 389 + ldapManager.bindDn = binding.bindDnEditText.text?.toString() ?: "" + ldapManager.password = binding.passwordEditText.text?.toString() ?: "" + ldapManager.baseDn = binding.baseDnEditText.text?.toString() ?: "" + + lifecycleScope.launch { + ldapManager.getContacts().fold( + onSuccess = { contacts -> + Toast.makeText( + this@SettingsActivity, + "연결 성공! ${contacts.size}개의 연락처를 찾았습니다.", + Toast.LENGTH_LONG + ).show() + }, + onFailure = { error -> + Toast.makeText( + this@SettingsActivity, + "연결 실패: ${error.message}", + Toast.LENGTH_LONG + ).show() + } + ) + } + } +} diff --git a/app/src/main/res/drawable/circle_background.xml b/app/src/main/res/drawable/circle_background.xml new file mode 100644 index 0000000..66b24b5 --- /dev/null +++ b/app/src/main/res/drawable/circle_background.xml @@ -0,0 +1,5 @@ + + + + diff --git a/app/src/main/res/layout/activity_contact_detail.xml b/app/src/main/res/layout/activity_contact_detail.xml new file mode 100644 index 0000000..711bef3 --- /dev/null +++ b/app/src/main/res/layout/activity_contact_detail.xml @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_edit_contact.xml b/app/src/main/res/layout/activity_edit_contact.xml new file mode 100644 index 0000000..2c1f963 --- /dev/null +++ b/app/src/main/res/layout/activity_edit_contact.xml @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..d8f06d9 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_settings.xml b/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 0000000..33106ca --- /dev/null +++ b/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_contact.xml b/app/src/main/res/layout/item_contact.xml new file mode 100644 index 0000000..cf2dec9 --- /dev/null +++ b/app/src/main/res/layout/item_contact.xml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml new file mode 100644 index 0000000..110cf34 --- /dev/null +++ b/app/src/main/res/menu/main_menu.xml @@ -0,0 +1,17 @@ + + + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..bba8a0e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..bba8a0e Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..08cbbb6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..08cbbb6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..71d669d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..71d669d Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..19421fb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..19421fb Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..fab3768 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..fab3768 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..55b29ad --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,18 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + #2196F3 + #1976D2 + #FF5722 + #F5F5F5 + #FFFFFF + #212121 + #757575 + #BDBDBD + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..3ddce5d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,31 @@ + + + 외부 주소록 + 이름 또는 전화번호 검색 + 연락처 추가 + 연락처 수정 + 연락처 삭제 + 이름 + 별명 + 전화번호 + 이메일 + 회사 + 부서 + 직함 + 저장 + 취소 + 삭제 + 정말 삭제하시겠습니까? + 불러오는 중... + 연락처가 없습니다 + 서버 연결 실패 + 설정 + LDAP 서버 + 포트 + Bind DN + 비밀번호 + Base DN + 전화걸기 + 문자보내기 + 연락처 동기화 + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..02f5088 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,9 @@ + + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..93aa190 --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +plugins { + id 'com.android.application' version '8.1.0' apply false + id 'org.jetbrains.kotlin.android' version '1.9.0' apply false +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..f0a2e55 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official +android.nonTransitiveRClass=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d64cd49 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..3fa8f86 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..6689b85 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..8d041cb --- /dev/null +++ b/settings.gradle @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url 'https://jitpack.io' } + } +} + +rootProject.name = "LDAPContacts" +include ':app'