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
21
.gitignore
vendored
Normal file
@ -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
|
||||
121
README.md
Normal file
@ -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<List<Contact>>
|
||||
|
||||
// 연락처 검색 (이름, 전화번호, 회사명)
|
||||
suspend fun searchContacts(query: String): Result<List<Contact>>
|
||||
|
||||
// DN으로 연락처 조회
|
||||
suspend fun getContactByDn(dn: String): Contact?
|
||||
|
||||
// 연락처 추가
|
||||
suspend fun addContact(contact: Contact): Result<Unit>
|
||||
|
||||
// 연락처 수정
|
||||
suspend fun updateContact(contact: Contact): Result<Unit>
|
||||
|
||||
// 연락처 삭제
|
||||
suspend fun deleteContact(contact: Contact): Result<Unit>
|
||||
```
|
||||
|
||||
## 연락처 동기화 로직
|
||||
|
||||
`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
|
||||
62
app/build.gradle
Normal file
@ -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'
|
||||
}
|
||||
3
app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
# UnboundID LDAP SDK
|
||||
-keep class com.unboundid.** { *; }
|
||||
-dontwarn com.unboundid.**
|
||||
43
app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
|
||||
<uses-permission android:name="android.permission.GET_ACCOUNTS" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.LDAPContacts"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:targetApi="31">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ContactDetailActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".EditContactActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
44
app/src/main/java/net/ioresponse/ldapcontacts/Contact.kt
Normal file
@ -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<String, String>): 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"] ?: ""
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Contact>,
|
||||
private val onItemClick: (Contact) -> Unit,
|
||||
private val onCallClick: (Contact) -> Unit
|
||||
) : RecyclerView.Adapter<ContactAdapter.ViewHolder>() {
|
||||
|
||||
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<Contact>) {
|
||||
contacts = newContacts
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<Contact>): Result<SyncResult> = 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<Long>()
|
||||
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<Long>()
|
||||
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<ContentProviderOperation>()
|
||||
|
||||
// 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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
263
app/src/main/java/net/ioresponse/ldapcontacts/LdapManager.kt
Normal file
@ -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<List<Contact>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = getConnection()
|
||||
val contacts = mutableListOf<Contact>()
|
||||
|
||||
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<String, String>()
|
||||
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<List<Contact>> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = getConnection()
|
||||
val contacts = mutableListOf<Contact>()
|
||||
|
||||
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<String, String>()
|
||||
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<String, String>()
|
||||
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<Unit> = 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<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = getConnection()
|
||||
|
||||
val modifications = mutableListOf<Modification>()
|
||||
|
||||
// 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<Unit> = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val connection = getConnection()
|
||||
connection.delete(contact.dn)
|
||||
connection.close()
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
241
app/src/main/java/net/ioresponse/ldapcontacts/MainActivity.kt
Normal file
@ -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<Contact>()
|
||||
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<out String>,
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
5
app/src/main/res/drawable/circle_background.xml
Normal file
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/primary" />
|
||||
</shape>
|
||||
224
app/src/main/res/layout/activity_contact_detail.xml
Normal file
@ -0,0 +1,224 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/primary"
|
||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
app:titleTextColor="@color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/initialsText"
|
||||
android:layout_width="100dp"
|
||||
android:layout_height="100dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:background="@drawable/circle_background"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="40sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/nameText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="16dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="24sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/callButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/call"
|
||||
app:icon="@android:drawable/sym_action_call" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/messageButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/message"
|
||||
app:icon="@android:drawable/sym_action_email"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
app:cardCornerRadius="12dp">
|
||||
|
||||
<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/phone"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/phoneText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/email"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emailText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/company"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/orgText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/department"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/deptText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginVertical="12dp"
|
||||
android:background="@color/divider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/editButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/edit_contact"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/deleteButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/delete"
|
||||
android:textColor="@color/white"
|
||||
app:backgroundTint="#F44336" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
155
app/src/main/res/layout/activity_edit_contact.xml
Normal file
@ -0,0 +1,155 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/primary"
|
||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
app:titleTextColor="@color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/name"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/nameEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPersonName" />
|
||||
|
||||
</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/nickname"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/nicknameEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPersonName" />
|
||||
|
||||
</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/phone"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/phoneEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="phone" />
|
||||
|
||||
</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/email"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/emailEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textEmailAddress" />
|
||||
|
||||
</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/company"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/companyEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</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/department"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/departmentEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</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/title"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/titleEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/save" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
89
app/src/main/res/layout/activity_main.xml
Normal file
@ -0,0 +1,89 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/primary"
|
||||
app:title="@string/app_name"
|
||||
app:titleTextColor="@color/white" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="8dp"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||
app:boxBackgroundColor="@color/white">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/searchEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/search_hint"
|
||||
android:inputType="text"
|
||||
android:imeOptions="actionSearch"
|
||||
android:drawableStart="@android:drawable/ic_menu_search"
|
||||
android:drawablePadding="8dp" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefresh"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/contactsRecyclerView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipToPadding="false"
|
||||
android:paddingBottom="80dp" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/emptyText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:text="@string/no_contacts"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fabAdd"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="16dp"
|
||||
android:contentDescription="@string/add_contact"
|
||||
android:src="@android:drawable/ic_input_add"
|
||||
app:backgroundTint="@color/primary" />
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
128
app/src/main/res/layout/activity_settings.xml
Normal file
@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout 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">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
android:background="@color/primary"
|
||||
app:navigationIcon="@android:drawable/ic_menu_close_clear_cancel"
|
||||
app:title="@string/settings"
|
||||
app:titleTextColor="@color/white" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/ldap_server"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/serverEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</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/ldap_port"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/portEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="number" />
|
||||
|
||||
</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/ldap_bind_dn"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/bindDnEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</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/ldap_password"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/passwordEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword" />
|
||||
|
||||
</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/ldap_base_dn"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/baseDnEditText"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/saveButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:text="@string/save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/testButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="연결 테스트"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
71
app/src/main/res/layout/item_contact.xml
Normal file
@ -0,0 +1,71 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView 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="wrap_content"
|
||||
android:layout_marginHorizontal="8dp"
|
||||
android:layout_marginVertical="4dp"
|
||||
app:cardCornerRadius="8dp"
|
||||
app:cardElevation="2dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:padding="12dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/initialsText"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="@drawable/circle_background"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="12dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/nameText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/phoneText"
|
||||
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/orgText"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/callButton"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/call"
|
||||
android:src="@android:drawable/sym_action_call" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
17
app/src/main/res/menu/main_menu.xml
Normal file
@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_sync"
|
||||
android:icon="@android:drawable/stat_notify_sync"
|
||||
android:title="@string/sync_contacts"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_settings"
|
||||
android:icon="@android:drawable/ic_menu_preferences"
|
||||
android:title="@string/settings"
|
||||
app:showAsAction="ifRoom" />
|
||||
|
||||
</menu>
|
||||
BIN
app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
18
app/src/main/res/values/colors.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<color name="primary">#2196F3</color>
|
||||
<color name="primary_dark">#1976D2</color>
|
||||
<color name="accent">#FF5722</color>
|
||||
<color name="background">#F5F5F5</color>
|
||||
<color name="card_background">#FFFFFF</color>
|
||||
<color name="text_primary">#212121</color>
|
||||
<color name="text_secondary">#757575</color>
|
||||
<color name="divider">#BDBDBD</color>
|
||||
</resources>
|
||||
31
app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">외부 주소록</string>
|
||||
<string name="search_hint">이름 또는 전화번호 검색</string>
|
||||
<string name="add_contact">연락처 추가</string>
|
||||
<string name="edit_contact">연락처 수정</string>
|
||||
<string name="delete_contact">연락처 삭제</string>
|
||||
<string name="name">이름</string>
|
||||
<string name="nickname">별명</string>
|
||||
<string name="phone">전화번호</string>
|
||||
<string name="email">이메일</string>
|
||||
<string name="company">회사</string>
|
||||
<string name="department">부서</string>
|
||||
<string name="title">직함</string>
|
||||
<string name="save">저장</string>
|
||||
<string name="cancel">취소</string>
|
||||
<string name="delete">삭제</string>
|
||||
<string name="confirm_delete">정말 삭제하시겠습니까?</string>
|
||||
<string name="loading">불러오는 중...</string>
|
||||
<string name="no_contacts">연락처가 없습니다</string>
|
||||
<string name="connection_error">서버 연결 실패</string>
|
||||
<string name="settings">설정</string>
|
||||
<string name="ldap_server">LDAP 서버</string>
|
||||
<string name="ldap_port">포트</string>
|
||||
<string name="ldap_bind_dn">Bind DN</string>
|
||||
<string name="ldap_password">비밀번호</string>
|
||||
<string name="ldap_base_dn">Base DN</string>
|
||||
<string name="call">전화걸기</string>
|
||||
<string name="message">문자보내기</string>
|
||||
<string name="sync_contacts">연락처 동기화</string>
|
||||
</resources>
|
||||
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.LDAPContacts" 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>
|
||||
4
build.gradle
Normal file
@ -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
|
||||
}
|
||||
4
gradle.properties
Normal file
@ -0,0 +1,4 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
android.nonTransitiveRClass=true
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
249
gradlew
vendored
Executable file
@ -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" "$@"
|
||||
92
gradlew.bat
vendored
Normal file
@ -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
|
||||
18
settings.gradle
Normal file
@ -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'
|
||||