This commit is contained in:
2025-12-03 14:21:00 +08:00
commit a9a702d381
121 changed files with 8767 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

3
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,3 @@
# 默认忽略的文件
/shelf/
/workspace.xml

6
.idea/AndroidProjectSystem.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>

6
.idea/compiler.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>

21
.idea/deploymentTargetSelector.xml generated Normal file
View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
</SelectionState>
<SelectionState runConfigName="MainActivity">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-11-18T04:17:46.332007300Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=SKC6LF4HQW9T45LV" />
</handle>
</Target>
</DropdownSelection>
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>

19
.idea/gradle.xml generated Normal file
View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleJvm" value="#JAVA_HOME" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>

View File

@@ -0,0 +1,57 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>

6
.idea/kotlinc.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinJpsPluginSettings">
<option name="version" value="2.0.0" />
</component>
</project>

10
.idea/migrations.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>

10
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="ms-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

17
.idea/runConfigurations.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

1
app/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/build

BIN
app/KeyStore.jks Normal file

Binary file not shown.

86
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,86 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.example.initiateaphonecallapp"
compileSdk = 36
defaultConfig {
applicationId = "com.example.initiateaphonecallapp"
minSdk = 28
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.volley)
implementation(libs.androidx.espresso.core)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.0")
// 添加网络相关依赖
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.navigation:navigation-compose:2.7.7")
// 添加权限请求依赖
implementation("com.google.accompanist:accompanist-permissions:0.32.0")
// 添加 Lifecycle 相关依赖
implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.2")
implementation("androidx.lifecycle:lifecycle-common-java8:2.6.2")
// 如果需要 Service 与 Lifecycle 集成
implementation("androidx.lifecycle:lifecycle-service:2.6.2")
implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.4.0")
implementation("androidx.compose.ui:ui:1.3.3")
implementation("androidx.compose.material3:material3:1.1.1")
}

21
app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.example.initiateaphonecallapp",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 28
}

BIN
app/release/惠易融.apk Normal file

Binary file not shown.

View File

@@ -0,0 +1,24 @@
package com.example.initiateaphonecallapp
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.example.initiateaphonecallapp", appContext.packageName)
}
}

View File

@@ -0,0 +1,84 @@
<?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-feature
android:name="android.hardware.telephony"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- 电话相关权限 -->
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- 录音权限 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<!-- 存储权限Android 10+ 需要分区存储适配) -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" /> <!-- 仅对 Android 9 及以下需要 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<!-- 后台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Android 13+ 需要的新权限 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:mimeType="audio/*" />
</intent>
</queries>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<application
android:allowBackup="true"
android:requestLegacyExternalStorage="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.InitiateAPhoneCallApp"
tools:targetApi="31"
android:usesCleartextTraffic="true">
<!-- 注册 WebSocket 服务 -->
<service
android:name=".service.WebSocketService"
android:enabled="true"
android:exported="false"
/>
<!-- 注册电话服务 -->
<service
android:name=".service.PhoneCallService"
android:enabled="true"
android:exported="false" />
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.InitiateAPhoneCallApp">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.settings.MANAGE_APP_ALL_FILES_ACCESS_PERMISSION" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,157 @@
package com.example.initiateaphonecallapp
import android.Manifest
import android.app.AlertDialog
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.os.Environment
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.example.initiateaphonecallapp.manager.CallRecordManager
import com.example.initiateaphonecallapp.manager.PreferenceManager
import com.example.initiateaphonecallapp.manager.StoragePermissionManager
import com.example.initiateaphonecallapp.ui.LoginSms.LoginSmsScreen
import com.example.initiateaphonecallapp.ui.MainScreen
import com.example.initiateaphonecallapp.ui.home.HomeScreen
import com.example.initiateaphonecallapp.ui.login.LoginScreen
import com.example.initiateaphonecallapp.ui.theme.InitiateAPhoneCallAppTheme
class MainActivity : ComponentActivity() {
private val requiredPermissions = arrayOf(
Manifest.permission.CALL_PHONE,
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.RECORD_AUDIO,
Manifest.permission.POST_NOTIFICATIONS,
Manifest.permission.READ_EXTERNAL_STORAGE, // 添加文件搜索权限
Manifest.permission.WRITE_EXTERNAL_STORAGE, // 添加文件搜索权限
Manifest.permission.MANAGE_EXTERNAL_STORAGE
)
// 在您的Composable函数中
private fun showAllFilesAccessDialog() {
AlertDialog.Builder(this)
.setTitle("需要文件访问权限")
.setMessage("为了访问通话录音文件,需要授予\"所有文件访问权限\"。请点击确定前往设置页面授权。")
.setPositiveButton("确定") { _, _ ->
storagePermissionManager.requestAllFilesAccess(this)
}
.setNegativeButton("取消") { _, _ ->
// 即使用户拒绝,也继续初始化应用,但文件访问功能可能受限
}
.setCancelable(false)
.show()
}
private lateinit var storagePermissionManager: StoragePermissionManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
CallRecordManager.initialize(this)
println("🚀 Application: CallRecordManager 初始化完成")
storagePermissionManager = StoragePermissionManager(this)
// 检查所有文件访问权限
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
!Environment.isExternalStorageManager()) {
showAllFilesAccessDialog()
} else {
}
// 初始化 PreferenceManager
PreferenceManager.initialize(this.applicationContext)
// 检查并请求权限
if (!hasAllPermissions()) {
requestPermissions(requiredPermissions, PERMISSION_REQUEST_CODE)
}
enableEdgeToEdge()
setContent {
InitiateAPhoneCallAppTheme {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "login"
) {
composable("login") {
LoginScreen(
onLoginSuccess = { token ->
// 登录成功后跳转到主页,并传递 token
navController.navigate("main/$token") {
popUpTo("login") { inclusive = true }
}
}
)
if (false) {
LoginScreen(
onLoginSuccess = { token ->
// 登录成功后跳转到主页,并传递 token
navController.navigate("main/$token") {
popUpTo("login") { inclusive = true }
}
}
)
} else {
LoginSmsScreen(
onLoginSuccess = { token ->
// 登录成功后跳转到主页,并传递 token
navController.navigate("main/$token") {
popUpTo("login") { inclusive = true }
}
}
)
}
}
composable("main/{token}") { backStackEntry ->
val token = backStackEntry.arguments?.getString("token") ?: ""
MainScreen(
authToken = token,
onLogout = {
PreferenceManager.clearLoginInfo()
navController.navigate("login") {
popUpTo("main") { inclusive = true }
}
}
)
}
}
}
}
}
private fun hasAllPermissions(): Boolean {
return requiredPermissions.all { permission ->
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == PERMISSION_REQUEST_CODE) {
if (grantResults.all { it == PackageManager.PERMISSION_GRANTED }) {
println("✅ 所有权限已授予")
} else {
println("❌ 某些权限被拒绝")
// 可以在这里提示用户权限被拒绝的影响
}
}
}
companion object {
private const val PERMISSION_REQUEST_CODE = 1001
}
}

View File

@@ -0,0 +1,94 @@
// data/model/ApiCallRecord.kt
package com.example.initiateaphonecallapp.data.model
import com.example.initiateaphonecallapp.enums.CallStatus
import com.google.gson.annotations.SerializedName
import java.text.SimpleDateFormat
import java.time.LocalDateTime
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* 后端返回的通话记录数据结构
*/
data class ApiCallRecord(
@SerializedName("id")
val id: Long,
@SerializedName("userId")
val userId: Long,
@SerializedName("phone")
val phone: String,
@SerializedName("duration")
val duration: Int, // 单位:秒
@SerializedName("status")
val status: Boolean, // true表示成功false表示失败
@SerializedName("fileUrl")
val fileUrl: String?,
@SerializedName("createTime")
val createTime: String // ISO 8601格式2025-11-18T09:51:41
)
/**
* 后端返回的包装结构
*/
data class ApiResponse<T>(
@SerializedName("msg")
val message: String,
@SerializedName("code")
val code: Int,
@SerializedName("data")
val data: T
)
data class CallRecordRequest(
val startTime: String? = null,
val endTime: String? = null
)
// 扩展函数将ApiCallRecord转换为前端的CallRecord
fun ApiCallRecord.toCallRecord(): CallRecord {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
val startTime = dateFormat.parse(createTime) ?: Date()
// 计算结束时间(开始时间 + 持续时间)
val endTime = if (duration > 0) {
Calendar.getInstance().apply {
time = startTime
add(Calendar.SECOND, duration)
}.time
} else {
null
}
return CallRecord(
id = id.toString(),
phoneNumber = phone,
startTime = startTime,
endTime = endTime,
duration = duration.toLong(),
status = if (status) CallStatus.COMPLETED else CallStatus.FAILED,
audioFileName = extractFileNameFromUrl(fileUrl),
audioFileUri = fileUrl,
uploaded = fileUrl != null && fileUrl.isNotEmpty(),
uploadTime = if (fileUrl != null && fileUrl.isNotEmpty()) startTime else null
)
}
// 辅助函数从URL中提取文件名
private fun extractFileNameFromUrl(url: String?): String? {
return url?.substringAfterLast('/')?.substringBefore('?')
}
// 扩展函数:批量转换
fun List<ApiCallRecord>.toCallRecords(): List<CallRecord> {
return this.map { it.toCallRecord() }
}

View File

@@ -0,0 +1,7 @@
package com.example.initiateaphonecallapp.data.model
data class ApiContactsRequest (
val name: String = "",
val phone: String = "",
)

View File

@@ -0,0 +1,6 @@
package com.example.initiateaphonecallapp.data.model
data class ApiContactsResponse(
val msg: String="",
val code:Int
)

View File

@@ -0,0 +1,6 @@
package com.example.initiateaphonecallapp.data.model
data class ApiLoginSmsResponse(
val phone:String = "",
val code:String="",
)

View File

@@ -0,0 +1,90 @@
package com.example.initiateaphonecallapp.data.model
import java.util.*
// 主响应类
data class ApigetInfoResponse(
val msg: String,
val code: Int,
val permissions: List<String>,
val roles: List<String>,
val isDefaultModifyPwd: Boolean,
val isPasswordExpired: Boolean,
val user: User1
)
// 用户信息类
data class User1(
val createBy: String?,
val createTime: String?,
val updateBy: String?,
val updateTime: String?,
val remark: String?,
val params: Map<String, Any>?,
val userId: Long,
val deptId: Long,
val userName: String,
val nickName: String,
val email: String?,
val phonenumber: String?,
val sex: String,
val avatar: String?,
val password: String,
val status: String,
val delFlag: String,
val loginIp: String?,
val loginDate: String?,
val pwdUpdateDate: String?,
val dept: Dept,
val roles: List<Role>,
val roleIds: List<Long>?,
val postIds: List<Long>?,
val roleId: Long?,
val admin: Boolean
)
// 部门信息类
data class Dept(
val createBy: String?,
val createTime: String?,
val updateBy: String?,
val updateTime: String?,
val remark: String?,
val params: Map<String, Any>?,
val deptId: Long,
val parentId: Long,
val ancestors: String,
val deptName: String,
val orderNum: Int,
val leader: String?,
val phone: String?,
val email: String?,
val status: String,
val delFlag: String?,
val parentName: String?,
val children: List<Dept>
)
// 角色信息类
data class Role(
val createBy: String?,
val createTime: String?,
val updateBy: String?,
val updateTime: String?,
val remark: String?,
val params: Map<String, Any>?,
val roleId: Long,
val roleName: String,
val roleKey: String,
val roleSort: Int,
val dataScope: String,
val menuCheckStrictly: Boolean,
val deptCheckStrictly: Boolean,
val status: String,
val delFlag: String?,
val flag: Boolean,
val menuIds: List<Long>?,
val deptIds: List<Long>?,
val permissions: List<String>,
val admin: Boolean
)

View File

@@ -0,0 +1,45 @@
package com.example.initiateaphonecallapp.data.model
import android.net.Uri
import android.provider.MediaStore
data class AudioFile(
val id: Long,
val displayName: String,
val title: String,
val artist: String,
val album: String,
val duration: Long,
val size: Long,
val dateAdded: Long,
val dateModified: Long,
val contentUri: Uri,
val filePath: String? = null
) {
val formattedDuration: String
get() = formatDuration(duration)
val formattedSize: String
get() = formatFileSize(size)
}
private fun formatDuration(milliseconds: Long): String {
val seconds = milliseconds / 1000
val minutes = seconds / 60
val hours = minutes / 60
return if (hours > 0) {
String.format("%02d:%02d:%02d", hours, minutes % 60, seconds % 60)
} else {
String.format("%02d:%02d", minutes, seconds % 60)
}
}
private fun formatFileSize(size: Long): String {
return when {
size < 1024 -> "$size B"
size < 1024 * 1024 -> "${String.format("%.1f", size / 1024.0)} KB"
size < 1024 * 1024 * 1024 -> "${String.format("%.1f", size / (1024.0 * 1024.0))} MB"
else -> "${String.format("%.1f", size / (1024.0 * 1024.0 * 1024.0))} GB"
}
}

View File

@@ -0,0 +1,15 @@
package com.example.initiateaphonecallapp.data.model
import android.net.Uri
data class AudioFileInfo(
val uri: Uri,
val fileName: String,
val fileSize: Long? = null,
val duration: Long? = null, // 音频时长(毫秒)
val mimeType: String? = null,
val artist: String? = null,
val album: String? = null,
val dateAdded: Long? = null,
val displayName: String? = null
)

View File

@@ -0,0 +1,36 @@
// data/model/CallRecord.kt
package com.example.initiateaphonecallapp.data.model
import com.example.initiateaphonecallapp.enums.CallStatus
import java.text.SimpleDateFormat
import java.util.*
data class CallRecord(
val id: String = UUID.randomUUID().toString(),
val phoneNumber: String,
val startTime: Date,
val endTime: Date? = null,
val duration: Long = 0, // 单位:秒
val status: CallStatus = CallStatus.INITIATED,
val audioFileName: String? = null,
val audioFileUri: String? = null,
val uploaded: Boolean = false,
val uploadTime: Date? = null
) {
val formattedDuration: String
get() {
if (duration <= 0) return "0秒"
val minutes = duration / 60
val seconds = duration % 60
return if (minutes > 0) "${minutes}${seconds}" else "${seconds}"
}
val formattedStartTime: String
get() = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(startTime)
val formattedEndTime: String
get() = endTime?.let {
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(it)
} ?: "进行中"
}

View File

@@ -0,0 +1,41 @@
package com.example.initiateaphonecallapp.data.model
import com.example.initiateaphonecallapp.enums.CallStatus
import com.google.gson.annotations.SerializedName
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
data class CallRecordUploadRequest(
@SerializedName("phone")
val phone: String,
@SerializedName("duration")
val duration: Long,
@SerializedName("status")
val status: Boolean,
@SerializedName("fileUrl")
val fileUrl: String?,
@SerializedName("createTime")
val createTime: String
) {
companion object {
fun fromCallRecord(callRecord: CallRecord, audioFileUrl: String? = null): CallRecordUploadRequest {
return CallRecordUploadRequest(
phone = callRecord.phoneNumber,
duration = callRecord.duration,
status = callRecord.status == CallStatus.COMPLETED, // 根据你的业务逻辑调整
fileUrl = audioFileUrl ?: callRecord.audioFileUri,
createTime = formatDateTimeForApi(callRecord.startTime)
)
}
private fun formatDateTimeForApi(date: Date): String {
val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
return sdf.format(date)
}
}
}

View File

@@ -0,0 +1,9 @@
package com.example.initiateaphonecallapp.data.model
import com.google.gson.annotations.SerializedName
// 更新响应模型
data class CallRecordUploadResponse(
@SerializedName("code") val code: Int,
@SerializedName("msg") val message: String,
)

View File

@@ -0,0 +1,6 @@
// data/model/GetCodeRequest.kt
package com.example.initiateaphonecallapp.data.model
data class GetCodeRequest(
val phone: String
)

View File

@@ -0,0 +1,6 @@
package com.example.initiateaphonecallapp.data.model
data class LoginRequest(
val username: String,
val password: String
)

View File

@@ -0,0 +1,7 @@
package com.example.initiateaphonecallapp.data.model
data class LoginResponse(
val code: Int,
val token: String? = null,
val msg: String? = null,
)

View File

@@ -0,0 +1,7 @@
// data/model/LoginSmsRequest.kt
package com.example.initiateaphonecallapp.data.model
data class LoginSmsRequest(
val phone: String,
val code: String
)

View File

@@ -0,0 +1,29 @@
// data/model/PipelineResponse.kt
package com.example.initiateaphonecallapp.data.model
import java.io.Serializable
data class PipelineResponse(
val id: String = "",
val name: String = "",
val phone: String = "",
var status: Int = 0
) : Serializable {
// 状态文本显示
fun getStatusText(): String {
return when (status) {
0 -> "未联系"
1 -> "未接通"
else -> "已拨打"
}
}
// 状态颜色
fun getStatusColor(): Long {
return when (status) {
0 -> 0xFFFF5722 // 橙色
1 -> 0xFF2196F3 // 蓝色
else -> 0xFF757575 // 灰色
}
}
}

View File

@@ -0,0 +1,19 @@
package com.example.initiateaphonecallapp.data.model
// Profile.kt
data class Profile(
val userId: Long,
val userName: String,
val nickName: String,
val email: String,
val phonenumber: String,
val sex: String,
val avatar: String?,
val deptName: String,
val roleNames: List<String>,
val createTime: String,
val loginIp: String,
val loginDate: String,
val status: String
)

View File

@@ -0,0 +1,11 @@
package com.example.initiateaphonecallapp.data.model
import java.io.File
data class SearchResult(
val file: File,
val directory: String,
val fileName: String,
val fileSize: Long,
val lastModified: Long
)

View File

@@ -0,0 +1,8 @@
package com.example.initiateaphonecallapp.data.model
data class SearchState(
val isSearching: Boolean = false,
val results: List<SearchResult> = emptyList(),
val errorMessage: String? = null,
val searchedDirectories: List<String> = emptyList()
)

View File

@@ -0,0 +1,10 @@
package com.example.initiateaphonecallapp.data.model
import android.net.Uri
import kotlinx.coroutines.flow.StateFlow
data class SelectedAudioFile(
val uri: Uri,
val fileName: String,
val selectedAt: Long = System.currentTimeMillis()
)

View File

@@ -0,0 +1,11 @@
package com.example.initiateaphonecallapp.data.model
data class UploadResponse(
val code: Int,
val msg: String? = null,
val fileName: String? = null,
val newFileName: String? = null,
val url: String? = null,
val originalFilename: String? = null,
)

View File

@@ -0,0 +1,7 @@
package com.example.initiateaphonecallapp.data.model
data class User(
val id: String = "",
val username: String = "",
val password: String = "",
)

View File

@@ -0,0 +1,9 @@
package com.example.initiateaphonecallapp.data.model
import com.example.initiateaphonecallapp.enums.MessageType
import com.google.gson.annotations.SerializedName
data class WebSocketMessage(
@SerializedName("action") val action: MessageType,
@SerializedName("phone") val phone: String? = null,
)

View File

@@ -0,0 +1,15 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import com.example.initiateaphonecallapp.data.model.LoginRequest
import com.example.initiateaphonecallapp.data.model.LoginResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface ApiCallLogService {
@POST("app/call-log/my")
suspend fun callLog(@Body callRecordRequest: CallRecordRequest): Response<ApiResponse<List<ApiCallRecord>>>
}

View File

@@ -0,0 +1,18 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.POST
interface ApiContactsService {
@POST("app/contacts/add")
suspend fun addContact(@Body contactsRequest: ApiContactsRequest): Response<ApiContactsResponse>
@POST("app/contacts/list")
suspend fun listContacts(): Response<ApiResponse<List<ApiContactsRequest>>>
}

View File

@@ -0,0 +1,28 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.data.model.ApiLoginSmsResponse
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.ApigetInfoResponse
import com.example.initiateaphonecallapp.data.model.GetCodeRequest
import com.example.initiateaphonecallapp.data.model.LoginRequest
import com.example.initiateaphonecallapp.data.model.LoginResponse
import com.example.initiateaphonecallapp.data.model.LoginSmsRequest
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
// data/network/LoginApiService.kt
interface LoginApiService {
@POST("app/login")
suspend fun login(@Body loginRequest: LoginRequest): Response<LoginResponse>
@GET("getInfo")
suspend fun getInfo(): Response<ApigetInfoResponse>
@POST("getCode")
suspend fun getCode(@Body request: GetCodeRequest): Response<ApiResponse<Boolean>>
@POST("app/login-sms")
suspend fun loginSms(@Body request: LoginSmsRequest): Response<LoginResponse>
}

View File

@@ -0,0 +1,11 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.PipelineResponse
import retrofit2.Response
import retrofit2.http.POST
interface PipelineService {
@POST("pipeline/my")
suspend fun getPiplines():Response<ApiResponse<List<PipelineResponse>>>
}

View File

@@ -0,0 +1,74 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.manager.TokenManager
import com.google.gson.GsonBuilder
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
object RetrofitClient {
private const val BASE_URL = "http://120.26.58.34/prod-api/"
// private const val BASE_URL = "http://121.43.240.248/prod-api/"
// 创建日志拦截器
private val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
// 创建一个拦截器,用于在请求头中添加 token
private val authInterceptor = Interceptor { chain ->
val originalRequest = chain.request()
val token = TokenManager.getToken()
val newRequest = if (!token.isNullOrEmpty()) {
originalRequest.newBuilder()
.addHeader("Authorization", "Bearer $token")
.build()
} else {
originalRequest
}
chain.proceed(newRequest)
}
// 创建 OkHttpClient
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor(authInterceptor) // 添加认证拦截器
.build()
// 创建 Gson 实例
private val gson = GsonBuilder()
.setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
.create()
// 创建 Retrofit 实例
private val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
val loginApiService: LoginApiService by lazy {
retrofit.create(LoginApiService::class.java)
}
val uploadFileApiService: UploadFile by lazy {
retrofit.create(UploadFile::class.java)
}
val apiCallLogService: ApiCallLogService by lazy {
retrofit.create(ApiCallLogService::class.java)
}
val apiContactsService: ApiContactsService by lazy {
retrofit.create(ApiContactsService::class.java)
}
val pipelineService: PipelineService by lazy {
retrofit.create(PipelineService::class.java)
}
}

View File

@@ -0,0 +1,28 @@
package com.example.initiateaphonecallapp.data.network
import com.example.initiateaphonecallapp.data.model.CallRecordUploadRequest
import com.example.initiateaphonecallapp.data.model.CallRecordUploadResponse
import com.example.initiateaphonecallapp.data.model.LoginRequest
import com.example.initiateaphonecallapp.data.model.LoginResponse
import com.example.initiateaphonecallapp.data.model.UploadResponse
import okhttp3.MultipartBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
interface UploadFile {
// 上传文件
@Multipart
@POST("common/upload")
suspend fun uploadFile(
@Part file: MultipartBody.Part
): Response<UploadResponse>
// 上传通话记录 - 根据你的实际接口路径调整
@POST("app/call-log/add")
suspend fun uploadCallRecord(
@Body request: CallRecordUploadRequest
): Response<CallRecordUploadResponse>
}

View File

@@ -0,0 +1,97 @@
package com.example.initiateaphonecallapp.data.repository
import com.example.initiateaphonecallapp.data.model.GetCodeRequest
import com.example.initiateaphonecallapp.data.model.LoginRequest
import com.example.initiateaphonecallapp.data.model.LoginResponse
import com.example.initiateaphonecallapp.data.model.LoginSmsRequest
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import com.example.initiateaphonecallapp.data.network.RetrofitClient.loginApiService
import retrofit2.HttpException // 正确的导入
import java.io.IOException
class AuthRepository {
private val apiService = RetrofitClient.loginApiService
// 移除 @RequiresExtension 注解,它在这里不需要
suspend fun login(username: String, password: String): Result<LoginResponse> {
return try {
val request = LoginRequest(username, password)
val response = apiService.login(request)
println("Response code: ${response.code()}")
println("Response body: ${response.body()}")
if (response.isSuccessful && response.body() != null) {
val responseBody = response.body()!!
if (responseBody.code == 200) { // 假设200表示业务成功
Result.success(responseBody)
} else {
// 使用服务器返回的消息,如果没有则使用默认消息
val errorMessage = responseBody.msg ?: "登录失败"
Result.failure(Exception(errorMessage))
}
} else {
println("Login failed with error code: ${response.code()}")
// 获取具体的错误信息
val errorCode = response.code()
val errorMessage = when (errorCode) {
400 -> "请求参数错误"
401 -> "用户名或密码错误"
403 -> "访问被拒绝"
404 -> "接口不存在"
500 -> "服务器内部错误"
else -> "登录失败 (错误码: $errorCode)"
}
Result.failure(Exception(errorMessage))
}
} catch (e: HttpException) { // 现在这个会正确捕获 Retrofit 的 HTTP 异常
// 处理 HTTP 异常
val errorCode = e.code()
val errorMessage = when (errorCode) {
400 -> "请求参数错误"
401 -> "用户名或密码错误"
403 -> "访问被拒绝"
404 -> "接口不存在"
500 -> "服务器内部错误"
else -> "网络错误: $errorCode ${e.message()}"
}
Result.failure(Exception(errorMessage))
} catch (e: IOException) {
Result.failure(Exception("网络连接失败,请检查网络设置"))
} catch (e: Exception) {
Result.failure(Exception("未知错误: ${e.message}"))
}
}
// 获取验证码
suspend fun getCode(phone: String): Result<Boolean> {
return try {
val response = apiService.getCode(GetCodeRequest(phone))
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse?.code == 200 && apiResponse.data == true) {
Result.success(true)
} else {
Result.failure(Exception(apiResponse?.message ?: "获取验证码失败"))
}
} else {
Result.failure(Exception("网络请求失败: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
// 验证码登录
suspend fun loginSms(phone: String, code: String): Result<LoginResponse> {
return try {
val response = apiService.loginSms(LoginSmsRequest(phone, code))
if (response.isSuccessful) {
Result.success(response.body()!!)
} else {
Result.failure(Exception("Login failed: ${response.code()}"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
}

View File

@@ -0,0 +1,57 @@
// data/repository/CallLogRepository.kt
package com.example.initiateaphonecallapp.data.repository
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import com.example.initiateaphonecallapp.data.model.toCallRecords
import com.example.initiateaphonecallapp.data.network.ApiCallLogService
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
class CallLogRepository(
) {
private val apiCallLogService = RetrofitClient.apiCallLogService
/**
* 获取通话记录列表
* @param request 时间范围请求参数
* @return 通话记录列表的结果
*/
suspend fun getCallLogs(request: CallRecordRequest): Result<List<CallRecord>> {
return withContext(Dispatchers.IO) {
try {
val response = apiCallLogService.callLog(request)
handleResponse(response)
} catch (e: Exception) {
Result.failure(Exception("网络请求失败: ${e.message}"))
}
}
}
/**
* 处理 API 响应
*/
private fun handleResponse(response: Response<ApiResponse<List<ApiCallRecord>>>): Result<List<CallRecord>> {
return when {
response.isSuccessful -> {
val apiResponse = response.body()
when (apiResponse?.code) {
200 -> {
val callRecords = apiResponse.data?.toCallRecords() ?: emptyList()
Result.success(callRecords)
}
else -> {
Result.failure(Exception(apiResponse?.message ?: "未知错误"))
}
}
}
else -> {
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
}
}
}
}

View File

@@ -0,0 +1,87 @@
// data/repository/ContactsRepository.kt
package com.example.initiateaphonecallapp.data.repository
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.network.ApiContactsService
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
class ContactsRepository (
) {
private val apiContactsService = RetrofitClient.apiContactsService
/**
* 添加联系人
* @param name 联系人姓名
* @param phone 联系人电话
* @return 添加联系人的结果
*/
suspend fun addContact(name: String, phone: String): Result<ApiContactsResponse> {
return withContext(Dispatchers.IO) {
try {
val request = ApiContactsRequest(name = name, phone = phone)
val response = apiContactsService.addContact(request)
handleResponse(response)
} catch (e: Exception) {
Result.failure(Exception("添加联系人失败: ${e.message}"))
}
}
}
/**
* 获取联系人
*/
suspend fun listContacts(): Result<ApiResponse<List<ApiContactsRequest>>> {
return withContext(Dispatchers.IO) {
try {
val response = apiContactsService.listContacts()
handleResponse2(response)
} catch (e: Exception) {
Result.failure(Exception(" ${e.message}"))
}
}
}
/**
* 处理 API 响应
*/
private fun handleResponse(response: Response<ApiContactsResponse>): Result<ApiContactsResponse> {
return when {
response.isSuccessful -> {
val apiResponse = response.body()
when (apiResponse?.code) {
200 -> Result.success(apiResponse)
else -> {
Result.failure(Exception(apiResponse?.msg ?: "未知错误"))
}
}
}
else -> {
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
}
}
}
private fun handleResponse2(response: Response<ApiResponse<List<ApiContactsRequest>>>): Result<ApiResponse<List<ApiContactsRequest>>> {
return when {
response.isSuccessful -> {
val apiResponse = response.body()
when (apiResponse?.code) {
200 -> Result.success(apiResponse)
else -> {
Result.failure(Exception( "未知错误"))
}
}
}
else -> {
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.example.initiateaphonecallapp.data.repository
import com.example.initiateaphonecallapp.data.model.ApiCallRecord
import com.example.initiateaphonecallapp.data.model.ApiResponse
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import com.example.initiateaphonecallapp.data.model.PipelineResponse
import com.example.initiateaphonecallapp.data.model.toCallRecords
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import retrofit2.Response
class PiplineRepository {
private val pipelineService = RetrofitClient.pipelineService
suspend fun getPipelines(): Result<List<PipelineResponse>> {
return withContext(Dispatchers.IO) {
try {
val response = pipelineService.getPiplines()
handleResponse(response)
} catch (e: Exception) {
Result.failure(Exception("网络请求失败: ${e.message}"))
}
}
}
/**
* 处理 API 响应
*/
private fun handleResponse(response: Response<ApiResponse<List<PipelineResponse>>>): Result<List<PipelineResponse>> {
return when {
response.isSuccessful -> {
val apiResponse = response.body()
when (apiResponse?.code) {
200 -> {
val piplines = apiResponse.data
Result.success(piplines)
}
else -> {
Result.failure(Exception(apiResponse?.message ?: "未知错误"))
}
}
}
else -> {
Result.failure(Exception("网络错误: ${response.code()} ${response.message()}"))
}
}
}
}

View File

@@ -0,0 +1,169 @@
// data/repository/UploadRepository.kt
package com.example.initiateaphonecallapp.data.repository
import android.content.Context
import android.net.Uri
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.CallRecordUploadRequest
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import java.io.File
class UploadRepository(val context: Context) {
private val apiService = RetrofitClient.uploadFileApiService
suspend fun uploadAudioFile(fileUri: Uri, fileName: String): String? {
return try {
println("📤 UploadRepository: 开始上传音频文件: $fileName")
// 将 Uri 转换为 File
val file = uriToFile(fileUri) ?: throw IllegalArgumentException("无法读取文件")
// 创建请求体
val requestFile = RequestBody.create(
"audio/mpeg".toMediaTypeOrNull(), // 根据实际文件类型调整
file
)
// 创建 MultipartBody.Part
val filePart = MultipartBody.Part.createFormData(
"file",
fileName,
requestFile
)
// 执行上传
val response = apiService.uploadFile(filePart)
if (response.isSuccessful && response.body()?.code == 200) {
val fileUrl = response.body()?.url
println("✅ UploadRepository: 文件上传成功, URL: $fileUrl")
fileUrl
} else {
// val errorMsg = response.body()?.message ?: "上传失败"
println("❌ UploadRepository: 文件上传失败: $1")
null
}
} catch (e: Exception) {
println("❌ UploadRepository: 文件上传异常: ${e.message}")
e.printStackTrace()
null
}
}
suspend fun uploadCallRecord(callRecord: CallRecord, audioFileUrl: String? = null): Boolean {
return try {
println("📤 UploadRepository: 开始上传通话记录: ${callRecord.phoneNumber}")
// 使用新的请求格式
val request = CallRecordUploadRequest.fromCallRecord(callRecord, audioFileUrl)
println("📤 UploadRepository: 上传数据:")
println(" - phone: ${request.phone}")
println(" - duration: ${request.duration}")
println(" - status: ${request.status}")
println(" - fileUrl: ${request.fileUrl}")
println(" - createTime: ${request.createTime}")
val response = apiService.uploadCallRecord(request)
if (response.isSuccessful) {
val responseBody = response.body()
if (responseBody?.code == 200) {
println("✅ UploadRepository: 通话记录上传成功")
true
} else {
val errorMsg = responseBody?.message ?: "上传失败"
println("❌ UploadRepository: 通话记录上传失败: $errorMsg (code: ${responseBody?.code})")
false
}
} else {
val errorMsg = response.message()
println("❌ UploadRepository: 通话记录上传HTTP错误: $errorMsg (code: ${response.code()})")
false
}
} catch (e: Exception) {
println("❌ UploadRepository: 通话记录上传异常: ${e.message}")
e.printStackTrace()
false
}
}
// 上传通话记录和关联的音频文件
suspend fun uploadCallRecordWithAudio(callRecord: CallRecord): Boolean {
return try {
var audioFileUrl: String? = null
// 如果有音频文件,先上传音频文件
callRecord.audioFileUri?.let { audioUri ->
callRecord.audioFileName?.let { fileName ->
println("🎵 UploadRepository: 开始上传关联的音频文件")
audioFileUrl = uploadAudioFile(Uri.parse(audioUri), fileName)
if (audioFileUrl == null) {
println("⚠️ UploadRepository: 音频文件上传失败,但仍继续上传通话记录")
}
}
}
// 然后上传通话记录
uploadCallRecord(callRecord, audioFileUrl)
} catch (e: Exception) {
println("❌ UploadRepository: 完整上传过程异常: ${e.message}")
false
}
}
// 上传通话记录和关联的音频文件
suspend fun uploadCallRecordOnly(callRecord: CallRecord): Boolean {
return try {
var audioFileUrl: String? = null
// // 如果有音频文件,先上传音频文件
// callRecord.audioFileUri?.let { audioUri ->
// callRecord.audioFileName?.let { fileName ->
// println("🎵 UploadRepository: 开始上传关联的音频文件")
// audioFileUrl = uploadAudioFile(Uri.parse(audioUri), fileName)
// if (audioFileUrl == null) {
// println("⚠️ UploadRepository: 音频文件上传失败,但仍继续上传通话记录")
// }
// }
// }
// 然后上传通话记录
uploadCallRecord(callRecord, audioFileUrl)
} catch (e: Exception) {
println("❌ UploadRepository: 完整上传过程异常: ${e.message}")
false
}
}
// 将 Uri 转换为 File
private fun uriToFile(uri: Uri): File? {
return try {
when (uri.scheme) {
"file" -> File(uri.path!!)
"content" -> {
// 处理 content:// URI
val inputStream = context.contentResolver.openInputStream(uri)
val tempFile = File.createTempFile("upload_audio", ".tmp", context.cacheDir)
inputStream?.use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
tempFile
}
else -> null
}
} catch (e: Exception) {
println("❌ UploadRepository: URI 转换文件失败: ${e.message}")
null
}
}
}

View File

@@ -0,0 +1,12 @@
package com.example.initiateaphonecallapp.enums
enum class CallStatus {
DIALING, // 拨号中
CONNECTED, // 已接通
ENDED, // 已结束
FAILED, // 失败
INITIATED,//启动
COMPLETED,//完成
NOT_CONNECTED, // 未接通
}

View File

@@ -0,0 +1,11 @@
package com.example.initiateaphonecallapp.enums
import com.google.gson.annotations.SerializedName
enum class MessageType {
@SerializedName("call")
CALL,
@SerializedName("system")
SYSTEM
}

View File

@@ -0,0 +1,587 @@
// manager/CallRecordManager.kt
package com.example.initiateaphonecallapp.manager
import android.content.ContentUris
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import com.example.initiateaphonecallapp.data.model.AudioFile
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.SelectedAudioFile
import com.example.initiateaphonecallapp.data.repository.UploadRepository
import com.example.initiateaphonecallapp.enums.CallStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.util.Date
import java.util.UUID
object CallRecordManager {
private val _callRecords = MutableStateFlow<List<CallRecord>>(emptyList())
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
private val _currentCall = MutableStateFlow<CallRecord?>(null)
val currentCall: StateFlow<CallRecord?> = _currentCall.asStateFlow()
// 最近一次通话记录(等待上传)
private val _recentCall = MutableStateFlow<CallRecord?>(null)
val recentCall: StateFlow<CallRecord?> = _recentCall.asStateFlow()
// 上传状态
private val _uploadStatus = MutableStateFlow<UploadStatus>(UploadStatus.IDLE)
val uploadStatus: StateFlow<UploadStatus> = _uploadStatus.asStateFlow()
// 选择的音频文件状态流
private val _selectedAudioFile = MutableStateFlow<SelectedAudioFile?>(null)
val selectedAudioFile: StateFlow<SelectedAudioFile?> = _selectedAudioFile.asStateFlow()
// 上传仓库
private var uploadRepository: UploadRepository? = null
// 协程作用域
private val scope = CoroutineScope(Dispatchers.IO)
enum class UploadStatus {
IDLE, // 空闲
UPLOADING, // 上传中
SUCCESS, // 成功
FAILED // 失败
}
// 初始化方法,必须在 Application 或 Activity 中调用
fun initialize(context: Context) {
if (uploadRepository == null) {
uploadRepository = UploadRepository(context)
println("✅ CallRecordManager: 初始化完成")
}
}
// 设置选择的音频文件
fun setSelectedAudioFile(audioFile: SelectedAudioFile?) {
_selectedAudioFile.value = audioFile
println("🎵 CallRecordManager: 设置音频文件: ${audioFile?.fileName ?: "无"}")
// 当选择文件后,检查是否有最近的通话记录需要关联并上传
_recentCall.value?.let { recentCall ->
// 只有在通话已接通的情况下才关联音频文件
var s = recentCall.status == CallStatus.COMPLETED;
if (recentCall.status == CallStatus.COMPLETED &&
!recentCall.uploaded &&
recentCall.audioFileName == null) {
println("🔄 CallRecordManager: 发现已接通的通话记录,准备关联音频文件并上传")
updateAndUploadCallRecord(recentCall, audioFile)
}
}
}
// 获取当前选择的音频文件(添加这个方法)
fun getSelectedAudioFile(): SelectedAudioFile? {
return _selectedAudioFile.value
}
// 开始通话记录
fun startCallRecord(phoneNumber: String) {
val callRecord = CallRecord(
id = UUID.randomUUID().toString(),
phoneNumber = phoneNumber,
startTime = Date(),
status = CallStatus.DIALING
// 注意:这里不设置音频文件,等待用户选择后再关联
)
_currentCall.value = callRecord
println("📞 CallRecordManager: 开始通话记录 - 号码: $phoneNumber")
}
// 更新通话状态为连接
fun updateCallToConnected() {
_currentCall.value?.let { current ->
val updatedRecord = current.copy(
status = CallStatus.CONNECTED
)
_currentCall.value = updatedRecord
println("📞 CallRecordManager: 通话已连接")
}
}
fun endCallRecord() {
_currentCall.value?.let { current ->
val endTime = Date()
val duration = 0L
val completedCall = current.copy(
endTime = endTime,
duration = duration,
status = if (current.status == CallStatus.CONNECTED) {
CallStatus.COMPLETED
} else {
CallStatus.NOT_CONNECTED // 未接通状态
}
)
// 保存为最近通话记录(等待上传)
_recentCall.value = completedCall
_currentCall.value = null
println("📞 CallRecordManager: 通话结束 - 号码: ${completedCall.phoneNumber}, " +
"时长: ${completedCall.formattedDuration}, " +
"状态: ${completedCall.status}")
// 根据通话状态决定是否查找录音文件
if (completedCall.status == CallStatus.COMPLETED) {
// 已接通:自动查找并关联录音文件
scope.launch {
autoFindAndLinkCallRecord(completedCall)
}
} else {
// 未接通:直接上传通话记录(不关联录音文件)
println("📞 CallRecordManager: 通话未接通,直接上传记录")
uploadCallRecord(completedCall)
}
}
}
/**
* 自动查找并关联通话录音文件(仅在通话接通时调用)
*/
private suspend fun autoFindAndLinkCallRecord(callRecord: CallRecord) {
println("🔍 CallRecordManager: 开始自动查找通话录音文件...")
println(" 📞 目标号码: ${callRecord.phoneNumber}")
println(" 🕐 通话时间: ${callRecord.formattedStartTime}")
println(" 📍 通话状态: ${callRecord.status}")
try {
// 获取应用上下文
val context = uploadRepository?.context ?: run {
println("❌ CallRecordManager: UploadRepository 未初始化")
return
}
// 使用 AudioQueryManager 的查询逻辑
val audioFiles = queryAudioInTargetDirs(context)
if (audioFiles.isNotEmpty()) {
// 按修改时间倒序排序,选择最新的文件
val sortedFiles = audioFiles.sortedByDescending { it.dateModified }
val latestFile = sortedFiles.first()
println("✅ CallRecordManager: 找到 ${audioFiles.size} 个音频文件,选择最新的:")
println(" 🎵 文件名: ${latestFile.displayName}")
println(" 📁 路径: ${latestFile.filePath}")
println(" 🕐 修改时间: ${latestFile.dateModified}")
println(" 📊 文件大小: ${latestFile.size} 字节")
// 创建 SelectedAudioFile
val selectedAudioFile = SelectedAudioFile(
uri = latestFile.contentUri,
fileName = latestFile.displayName
)
// 设置选择的音频文件(这会触发上传)
withContext(Dispatchers.Main) {
setSelectedAudioFile(selectedAudioFile)
}
} else {
println("❌ CallRecordManager: 目标目录下没有任何音频文件")
// 即使没有找到录音文件,也要上传通话记录
_recentCall.value = callRecord.copy(
audioFileName = null,
audioFileUri = null
)
uploadCallRecord(callRecord)
}
} catch (e: Exception) {
println("❌ CallRecordManager: 自动查找录音文件失败: ${e.message}")
e.printStackTrace()
// 即使查找失败,也要上传通话记录
uploadCallRecord(callRecord)
}
}
// 使用音频文件更新通话记录并上传
private fun updateAndUploadCallRecord(callRecord: CallRecord, audioFile: SelectedAudioFile?) {
// 获取音频时长
val audioDuration = getAudioDuration(audioFile?.uri)
// 更新通话记录
val updatedRecord = callRecord.copy(
audioFileName = audioFile?.fileName,
audioFileUri = audioFile?.uri.toString(),
duration = audioDuration
)
// 添加到通话记录列表
_callRecords.value = listOf(updatedRecord) + _callRecords.value
_recentCall.value = updatedRecord
_uploadStatus.value = UploadStatus.UPLOADING
println("✅ CallRecordManager: 通话记录已关联音频文件")
println(" 📞 电话号码: ${updatedRecord.phoneNumber}")
println(" 🎵 音频文件: ${updatedRecord.audioFileName}")
println(" ⏱️ 通话时长: ${updatedRecord.formattedDuration}")
println(" 📍 通话状态: ${updatedRecord.status}")
// 触发上传
uploadCallRecord(updatedRecord)
}
private fun getAudioDuration(uri: Uri?): Long {
if (uri == null) return 0
return try {
val mmr = MediaMetadataRetriever()
mmr.setDataSource(uploadRepository?.context, uri)
val durationStr = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
mmr.release()
durationStr?.toLong()?.div(1000) ?: 0L
} catch (e: Exception) {
0L
}
}
// 通话失败记录
fun markCallAsFailed(error: String? = null) {
_currentCall.value?.let { current ->
val endTime = Date()
val duration = (endTime.time - current.startTime.time) / 1000
val failedCall = current.copy(
endTime = endTime,
duration = duration,
status = CallStatus.FAILED
)
_callRecords.value = listOf(failedCall) + _callRecords.value
_currentCall.value = null
println("❌ CallRecordManager: 通话失败 - 号码: ${failedCall.phoneNumber}, 错误: $error")
// 通话失败时也上传记录
uploadCallRecord(failedCall)
}
}
// 上传通话记录
private fun uploadCallRecord(callRecord: CallRecord) {
println("📤 CallRecordManager: 开始上传通话记录...")
println(" 📞 电话号码: ${callRecord.phoneNumber}")
println(" ⏱️ 通话时长: ${callRecord.formattedDuration}")
println(" 🎵 音频文件: ${callRecord.audioFileName ?: "无"}")
println(" 🕐 开始时间: ${callRecord.formattedStartTime}")
println(" 🕐 结束时间: ${callRecord.formattedEndTime}")
println(" 📍 通话状态: ${callRecord.status}")
// 调用真正的上传实现
uploadToServer(callRecord)
}
// 手动触发上传(用于重新上传)
fun manuallyUploadRecentCall() {
_recentCall.value?.let { callRecord ->
if (!callRecord.uploaded) {
_uploadStatus.value = UploadStatus.UPLOADING
uploadCallRecord(callRecord)
}
}
}
// 清除最近通话记录
fun clearRecentCall() {
_recentCall.value = null
_uploadStatus.value = UploadStatus.IDLE
}
// 真正的上传到服务器实现
private fun uploadToServer(callRecord: CallRecord) {
scope.launch {
try {
val repository = uploadRepository
if (repository == null) {
println("❌ CallRecordManager: UploadRepository 未初始化,请先调用 initialize() 方法")
_uploadStatus.value = UploadStatus.FAILED
return@launch
}
println("🚀 CallRecordManager: 开始真实上传流程")
println(" 📍 通话状态: ${callRecord.status}")
println(" 🎵 是否有录音文件: ${callRecord.audioFileName != null}")
// 根据通话状态决定上传方式
val success = if (callRecord.status == CallStatus.CONNECTED && callRecord.audioFileName != null) {
// 已接通且有录音文件:上传完整记录(包含录音)
repository.uploadCallRecordWithAudio(callRecord)
} else {
// 未接通或没有录音文件:只上传通话记录
repository.uploadCallRecordOnly(callRecord)
}
if (success) {
println("✅ CallRecordManager: 上传成功")
_uploadStatus.value = UploadStatus.SUCCESS
setSelectedAudioFile(null)
// 更新记录状态为已上传
_recentCall.value = callRecord.copy(uploaded = true, uploadTime = Date())
_callRecords.value = _callRecords.value.map { record ->
if (record.id == callRecord.id) {
record.copy(uploaded = true, uploadTime = Date())
} else {
record
}
}
// 3秒后重置上传状态
launch(Dispatchers.IO) {
kotlinx.coroutines.delay(3000)
_uploadStatus.value = UploadStatus.IDLE
}
} else {
println("❌ CallRecordManager: 上传失败")
_uploadStatus.value = UploadStatus.FAILED
}
} catch (e: Exception) {
println("❌ CallRecordManager: 上传异常: ${e.message}")
e.printStackTrace()
_uploadStatus.value = UploadStatus.FAILED
}
}
}
// 以下辅助方法保持不变...
private suspend fun queryAudioInTargetDirs(context: Context): List<AudioFile> = withContext(Dispatchers.IO) {
val audioList = mutableListOf<AudioFile>()
val TAG = "CallRecordManager"
// 目标目录列表
val targetDirectories = listOf(
"/storage/emulated/0/MIUI/sound_recorder/call_rec/", // 小米
"/storage/emulated/0/Record/call/", // 华为
"/storage/emulated/0/Sounds/CallRecord/", // 其他华为机型
"/storage/emulated/0/AudioRecord/", // 其他华为机型
"/storage/emulated/0/Music/Sound_recorder/Phone_recorder/"
)
Log.d(TAG, "开始查询目标目录音频文件")
// 首先尝试 MediaStore 查询
val mediaStoreFiles = queryAudioInTargetDirsViaMediaStore(context, targetDirectories)
audioList.addAll(mediaStoreFiles)
Log.d(TAG, "MediaStore 查询结果: ${mediaStoreFiles.size} 个文件")
// 如果 MediaStore 没有找到,尝试文件系统直接查询
if (mediaStoreFiles.isEmpty()) {
Log.d(TAG, "MediaStore 未找到文件,尝试文件系统查询...")
val fileSystemFiles = queryAudioInTargetDirsViaFileSystem(context, targetDirectories)
audioList.addAll(fileSystemFiles)
Log.d(TAG, "文件系统查询结果: ${fileSystemFiles.size} 个文件")
}
Log.d(TAG, "最终查询结果: 总共找到 ${audioList.size} 个音频文件")
return@withContext audioList
}
// MediaStore 查询
private suspend fun queryAudioInTargetDirsViaMediaStore(context: Context, targetDirectories: List<String>): List<AudioFile> = withContext(Dispatchers.IO) {
val audioList = mutableListOf<AudioFile>()
val contentResolver = context.contentResolver
val TAG = "CallRecordManager"
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.DATE_MODIFIED,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.MIME_TYPE
)
val sortOrder = "${MediaStore.Audio.Media.DATE_ADDED} DESC"
// 构建目录查询条件
val directoryConditions = targetDirectories.map {
"${MediaStore.Audio.Media.DATA} LIKE ?"
}
val selection = directoryConditions.joinToString(" OR ")
val selectionArgs = targetDirectories.map { "$it%" }.toTypedArray()
Log.d(TAG, "MediaStore 查询条件: $selection")
Log.d(TAG, "查询参数: ${selectionArgs.joinToString()}")
contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
Log.d(TAG, "MediaStore 查询结果: 找到 ${cursor.count} 条记录")
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val displayNameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val titleColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE)
val artistColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST)
val albumColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED)
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
val mimeTypeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.MIME_TYPE)
var processedCount = 0
while (cursor.moveToNext()) {
try {
val id = cursor.getLong(idColumn)
val displayName = cursor.getString(displayNameColumn) ?: "未知文件"
val title = cursor.getString(titleColumn) ?: displayName
val artist = cursor.getString(artistColumn) ?: "未知艺术家"
val album = cursor.getString(albumColumn) ?: "未知专辑"
val duration = cursor.getLong(durationColumn)
val size = cursor.getLong(sizeColumn)
val dateAdded = cursor.getLong(dateAddedColumn) * 1000
val dateModified = cursor.getLong(dateModifiedColumn) * 1000
val filePath = cursor.getString(dataColumn)
val mimeType = cursor.getString(mimeTypeColumn) ?: "unknown"
if (filePath != null && isInTargetDirectory(filePath, targetDirectories)) {
Log.d(TAG, "✅ MediaStore 找到文件: $filePath")
val contentUri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
audioList.add(
AudioFile(
id = id,
displayName = displayName,
title = title,
artist = artist,
album = album,
duration = duration,
size = size,
dateAdded = dateAdded,
dateModified = dateModified,
contentUri = contentUri,
filePath = filePath,
)
)
}
processedCount++
} catch (e: Exception) {
Log.e(TAG, "处理 MediaStore 记录时出错", e)
}
}
}
return@withContext audioList
}
// 辅助方法:检查文件是否在目标目录中
private fun isInTargetDirectory(filePath: String, targetDirectories: List<String>): Boolean {
return targetDirectories.any { filePath.startsWith(it) }
}
// 文件系统直接查询
private suspend fun queryAudioInTargetDirsViaFileSystem(context: Context, targetDirectories: List<String>): List<AudioFile> = withContext(Dispatchers.IO) {
val audioList = mutableListOf<AudioFile>()
val TAG = "CallRecordManager"
Log.d(TAG, "开始文件系统直接查询...")
targetDirectories.forEach { directoryPath ->
try {
val directory = File(directoryPath)
Log.d(TAG, "扫描目录: $directoryPath")
if (directory.exists() && directory.isDirectory) {
val files = try {
directory.listFiles() ?: emptyArray()
} catch (e: SecurityException) {
Log.w(TAG, "无权限访问目录: $directoryPath")
emptyArray()
}
Log.d(TAG, "找到 ${files.size} 个文件/目录")
files.forEach { file ->
Log.d(TAG, "检查: ${file.name} (${if (file.isFile) "文件" else "目录"})")
if (file.isFile && isAudioFile(file)) {
Log.d(TAG, "✅ 找到音频文件: ${file.absolutePath}")
try {
audioList.add(
AudioFile(
id = -1,
displayName = file.name,
title = file.name,
artist = "通话录音",
album = "通话录音",
duration = 0,
size = file.length(),
dateAdded = file.lastModified(),
dateModified = file.lastModified(),
contentUri = Uri.fromFile(file),
filePath = file.absolutePath,
)
)
} catch (e: SecurityException) {
Log.w(TAG, "无权限读取文件信息: ${file.absolutePath}")
} catch (e: Exception) {
Log.e(TAG, "读取文件信息失败: ${file.absolutePath}", e)
}
}
}
} else {
Log.w(TAG, "目录不存在: $directoryPath")
}
} catch (e: SecurityException) {
Log.e(TAG, "权限不足访问目录: $directoryPath")
} catch (e: Exception) {
Log.e(TAG, "扫描目录失败: $directoryPath", e)
}
}
return@withContext audioList
}
// 辅助方法:判断是否为音频文件
private fun isAudioFile(file: File): Boolean {
val extension = file.extension.lowercase()
val isAudio = extension in listOf("mp3", "m4a", "aac", "wav", "amr", "3gp", "ogg", "flac", "m4a", "wma")
Log.d("CallRecordManager", "文件 ${file.name} 扩展名: $extension, 是音频文件: $isAudio")
return isAudio
}
// 辅助方法:获取 MIME 类型
private fun getMimeType(file: File): String {
return when (file.extension.lowercase()) {
"mp3" -> "audio/mpeg"
"m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"wav" -> "audio/wav"
"amr" -> "audio/amr"
"3gp" -> "audio/3gpp"
"ogg" -> "audio/ogg"
"flac" -> "audio/flac"
else -> "audio/*"
}
}
}

View File

@@ -0,0 +1,140 @@
// manager/AudioPlayManager.kt
package com.example.initiateaphonecallapp.manager
import android.media.MediaPlayer
import android.util.Log
import java.io.IOException
object AudioPlayManager {
private const val TAG = "AudioPlayManager"
private var mediaPlayer: MediaPlayer? = null
private var currentAudioUrl: String? = null
private var isPlaying:Boolean = false
/**
* 播放音频
* @param audioUrl 音频文件URL可以是网络URL或本地文件路径
* @param onStart 播放开始回调
* @param onCompletion 播放完成回调
* @param onError 播放错误回调
*/
fun playAudio(
audioUrl: String,
onStart: (() -> Unit)? = null,
onCompletion: (() -> Unit)? = null,
onError: ((String) -> Unit)? = null
) {
// 如果正在播放同一个音频,则停止播放
if (isPlaying && currentAudioUrl == audioUrl) {
stopAudio()
return
}
// 停止之前的播放
stopAudio()
try {
mediaPlayer = MediaPlayer().apply {
// 设置数据源
setDataSource(audioUrl)
// 准备异步播放
setOnPreparedListener { mp ->
mp.start()
currentAudioUrl = audioUrl
onStart?.invoke()
Log.d(TAG, "开始播放音频: $audioUrl")
}
// 播放完成监听
setOnCompletionListener { mp ->
currentAudioUrl = null
onCompletion?.invoke()
Log.d(TAG, "音频播放完成: $audioUrl")
}
// 错误监听
setOnErrorListener { mp, what, extra ->
currentAudioUrl = null
val errorMsg = "播放错误: what=$what, extra=$extra"
onError?.invoke(errorMsg)
Log.e(TAG, errorMsg)
true
}
// 准备播放
prepareAsync()
}
} catch (e: IOException) {
val errorMsg = "音频文件无法访问: ${e.message}"
onError?.invoke(errorMsg)
Log.e(TAG, errorMsg)
} catch (e: IllegalArgumentException) {
val errorMsg = "音频文件格式错误: ${e.message}"
onError?.invoke(errorMsg)
Log.e(TAG, errorMsg)
} catch (e: SecurityException) {
val errorMsg = "没有音频播放权限: ${e.message}"
onError?.invoke(errorMsg)
Log.e(TAG, errorMsg)
}
}
/**
* 停止播放
*/
fun stopAudio() {
mediaPlayer?.let { mp ->
if (mp.isPlaying) {
mp.stop()
}
mp.release()
}
mediaPlayer = null
isPlaying = false
currentAudioUrl = null
Log.d(TAG, "停止音频播放")
}
/**
* 暂停播放
*/
fun pauseAudio() {
mediaPlayer?.let { mp ->
if (mp.isPlaying) {
mp.pause()
isPlaying = false
Log.d(TAG, "暂停音频播放")
}
}
}
/**
* 恢复播放
*/
fun resumeAudio() {
mediaPlayer?.let { mp ->
if (!mp.isPlaying && currentAudioUrl != null) {
mp.start()
isPlaying = true
Log.d(TAG, "恢复音频播放")
}
}
}
/**
* 获取当前播放状态
*/
fun isPlaying(): Boolean = isPlaying
/**
* 获取当前播放的音频URL
*/
fun getCurrentAudioUrl(): String? = currentAudioUrl
/**
* 检查是否正在播放指定音频
*/
fun isPlaying(audioUrl: String): Boolean = isPlaying && currentAudioUrl == audioUrl
}

View File

@@ -0,0 +1,117 @@
// manager/PreferenceManager.kt
package com.example.initiateaphonecallapp.manager
import android.content.Context
import android.content.SharedPreferences
object PreferenceManager {
private const val PREFS_NAME = "app_login_prefs"
// 键名常量
private const val KEY_USERNAME = "username"
private const val KEY_PASSWORD = "password"
private const val KEY_AUTH_TOKEN = "auth_token"
private const val KEY_REMEMBER_ME = "remember_me"
private const val KEY_AUTO_LOGIN = "auto_login"
// 不再需要 MyApplication改为在初始化时传入 Context
private var sharedPreferences: SharedPreferences? = null
// 初始化方法,在应用启动时调用
fun initialize(context: Context) {
sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
// 恢复 token
restoreToken()
println("🔐 PreferenceManager: 初始化完成")
}
private fun getPrefs(): SharedPreferences {
return sharedPreferences ?: throw IllegalStateException("PreferenceManager 未初始化,请先调用 initialize()")
}
// 保存登录信息
fun saveLoginInfo(
username: String,
password: String,
token: String,
rememberMe: Boolean = false,
autoLogin: Boolean = false
) {
getPrefs().edit().apply {
putString(KEY_USERNAME, username)
if (rememberMe || autoLogin) {
putString(KEY_PASSWORD, password)
} else {
remove(KEY_PASSWORD)
}
putString(KEY_AUTH_TOKEN, token)
putBoolean(KEY_REMEMBER_ME, rememberMe)
putBoolean(KEY_AUTO_LOGIN, autoLogin)
apply()
}
// 同时更新 TokenManager
TokenManager.setToken(token)
println("🔐 PreferenceManager: 登录信息已保存 - 用户: $username, 记住我: $rememberMe, 自动登录: $autoLogin")
}
// 获取保存的用户名
fun getSavedUsername(): String {
return getPrefs().getString(KEY_USERNAME, "") ?: ""
}
// 获取保存的密码
fun getSavedPassword(): String {
return getPrefs().getString(KEY_PASSWORD, "") ?: ""
}
// 获取保存的 token
fun getSavedToken(): String? {
return getPrefs().getString(KEY_AUTH_TOKEN, null)
}
// 是否启用了记住我
fun isRememberMe(): Boolean {
return getPrefs().getBoolean(KEY_REMEMBER_ME, false)
}
// 是否启用了自动登录
fun isAutoLogin(): Boolean {
return getPrefs().getBoolean(KEY_AUTO_LOGIN, false)
}
// 检查是否有保存的登录信息
fun hasSavedLoginInfo(): Boolean {
return getSavedUsername().isNotEmpty() && getSavedPassword().isNotEmpty()
}
// 检查是否有有效的 token
fun hasValidToken(): Boolean {
return !getSavedToken().isNullOrEmpty()
}
// 清除所有登录信息(退出登录时调用)
fun clearLoginInfo() {
getPrefs().edit().apply {
remove(KEY_USERNAME)
remove(KEY_PASSWORD)
remove(KEY_AUTH_TOKEN)
remove(KEY_REMEMBER_ME)
remove(KEY_AUTO_LOGIN)
apply()
}
// 同时清除 TokenManager 中的 token
TokenManager.clearToken()
println("🔐 PreferenceManager: 登录信息已清除")
}
// 恢复 token 到 TokenManager
fun restoreToken() {
val savedToken = getSavedToken()
savedToken?.let { token ->
TokenManager.setToken(token)
println("🔑 PreferenceManager: Token 已恢复")
} ?: run {
println("🔑 PreferenceManager: 没有保存的 Token")
}
}
}

View File

@@ -0,0 +1,78 @@
// StoragePermissionManager.kt
package com.example.initiateaphonecallapp.manager
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.util.Log
import androidx.core.content.ContextCompat
import android.Manifest
import android.content.pm.PackageManager
class StoragePermissionManager(private val context: Context) {
private val TAG = "StoragePermissionManager"
// 检查是否有所有文件访问权限
fun hasAllFilesAccess(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
// Android 10 以下默认有权限
hasReadWritePermissions()
}
}
// 检查读写权限Android 10 及以下)
private fun hasReadWritePermissions(): Boolean {
val readGranted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.READ_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
val writeGranted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
return readGranted && writeGranted
}
// 请求所有文件访问权限
fun requestAllFilesAccess(activity: Activity) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
try {
Log.d(TAG, "请求所有文件访问权限")
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, REQUEST_CODE_ALL_FILES_ACCESS)
} catch (e: Exception) {
Log.e(TAG, "启动权限请求失败", e)
// 备用方案:打开应用详情页面
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:${activity.packageName}")
activity.startActivityForResult(intent, REQUEST_CODE_APP_DETAILS)
}
}
}
// 检查是否可以从设置页面返回
fun onActivityResult(requestCode: Int, resultCode: Int): Boolean {
return when (requestCode) {
REQUEST_CODE_ALL_FILES_ACCESS, REQUEST_CODE_APP_DETAILS -> {
val hasAccess = hasAllFilesAccess()
Log.d(TAG, "权限请求返回,当前权限状态: $hasAccess")
hasAccess
}
else -> false
}
}
companion object {
const val REQUEST_CODE_ALL_FILES_ACCESS = 1002
const val REQUEST_CODE_APP_DETAILS = 1003
}
}

View File

@@ -0,0 +1,24 @@
// manager/TokenManager.kt
package com.example.initiateaphonecallapp.manager
object TokenManager {
private var _token: String? = null
fun setToken(token: String?) {
_token = token
println("🔑 TokenManager: Token 已设置")
}
fun getToken(): String? {
return _token
}
fun clearToken() {
_token = null
println("🔑 TokenManager: Token 已清除")
}
fun hasToken(): Boolean {
return !_token.isNullOrEmpty()
}
}

View File

@@ -0,0 +1,389 @@
// service/PhoneCallService.kt
package com.example.initiateaphonecallapp.service
import android.app.Service
import android.content.Intent
import android.content.pm.PackageManager
import android.media.MediaRecorder
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.telephony.PhoneStateListener
import android.telephony.TelephonyManager
import android.util.Log
import androidx.core.content.ContextCompat
import com.example.initiateaphonecallapp.manager.CallRecordManager
import java.io.File
import java.util.*
class PhoneCallService : Service() {
private var isInitialized = false
private var telephonyManager: TelephonyManager? = null
private var phoneStateListener: PhoneStateListener? = null
private var mediaRecorder: MediaRecorder? = null
private var recordingPath: String? = null
private var isRecording = false
// 自动拨号相关变量
private var isAutoDialing = false
private var currentDialIndex = 0
private var pipelineList: List<com.example.initiateaphonecallapp.data.model.PipelineResponse> = emptyList()
private val handler = Handler(Looper.getMainLooper())
private var autoDialRunnable: Runnable? = null
private var isCallInProgress = false
private var lastCallState = TelephonyManager.CALL_STATE_IDLE
companion object {
private const val TAG = "PhoneCallService"
private const val AUTO_DIAL_INTERVAL = 10000L // 10秒
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
Log.d(TAG, "✅ onCreate() 被调用")
telephonyManager = getSystemService(TELEPHONY_SERVICE) as TelephonyManager
initializePhoneListener()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(TAG, "✅ onStartCommand() 被调用")
// 确保电话监听器已初始化
if (!isInitialized) {
initializePhoneListener()
}
intent?.let { receivedIntent ->
when (receivedIntent.action) {
"ACTION_INITIATE_CALL" -> {
val phoneNumber = receivedIntent.getStringExtra("PHONE_NUMBER")
val pipelineId = receivedIntent.getStringExtra("PIPELINE_ID")
Log.d(TAG, "📞 收到拨号请求,号码: $phoneNumber, 管道ID: $pipelineId")
phoneNumber?.let { number ->
initiateCall(number, pipelineId)
}
}
"ACTION_START_AUTO_DIAL" -> {
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
pipelines?.let { list ->
startAutoDial(list)
} ?: run {
Log.e(TAG, "❌ 自动拨号启动失败:没有管道列表")
}
}
"ACTION_STOP_AUTO_DIAL" -> {
stopAutoDial()
}
// 新增:从指定位置开始自动拨号
"ACTION_START_AUTO_DIAL_FROM_CURRENT" -> {
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
val startIndex = receivedIntent.getIntExtra("START_INDEX", 0)
pipelines?.let { list ->
startAutoDialFromCurrent(list, startIndex)
} ?: run {
Log.e(TAG, "❌ 从指定位置自动拨号启动失败:没有管道列表")
}
}
// 新增:更新自动拨号索引
"ACTION_UPDATE_DIAL_INDEX" -> {
val pipelines = receivedIntent.getSerializableExtra("PIPELINE_LIST") as? ArrayList<com.example.initiateaphonecallapp.data.model.PipelineResponse>
val currentIndex = receivedIntent.getIntExtra("CURRENT_INDEX", 0)
pipelines?.let { list ->
updateAutoDialIndex(list, currentIndex)
} ?: run {
Log.e(TAG, "❌ 更新自动拨号索引失败:没有管道列表")
}
}
else -> {}
}
}
return START_REDELIVER_INTENT
}
private fun initializePhoneListener() {
if (!hasPhoneStatePermission()) {
Log.e(TAG, "❌ 没有权限设置电话状态监听器")
return
}
if (isInitialized) {
return
}
try {
phoneStateListener = object : PhoneStateListener() {
override fun onCallStateChanged(state: Int, phoneNumber: String?) {
Log.d(TAG, "📞 电话状态变化: $state, 号码: $phoneNumber, 上次状态: $lastCallState")
when (state) {
TelephonyManager.CALL_STATE_RINGING -> {
Log.d(TAG, "📳 来电状态: $phoneNumber")
isCallInProgress = false
lastCallState = state
}
TelephonyManager.CALL_STATE_OFFHOOK -> {
// 通话开始
Log.d(TAG, "📞 通话开始")
isCallInProgress = true
lastCallState = state
// 通知 CallRecordManager 通话已连接
CallRecordManager.updateCallToConnected()
}
TelephonyManager.CALL_STATE_IDLE -> {
// 通话结束 - 只有当之前是通话中时才处理
if (lastCallState == TelephonyManager.CALL_STATE_OFFHOOK) {
Log.d(TAG, "📴 通话结束")
isCallInProgress = false
// 通知 CallRecordManager 通话结束
CallRecordManager.endCallRecord()
// 如果正在自动拨号通话结束后10秒拨打下一个
if (isAutoDialing) {
Log.d(TAG, "⏰ 通话结束10秒后拨打下一个")
handler.postDelayed({
dialNextNumber()
}, AUTO_DIAL_INTERVAL)
}
} else {
Log.d(TAG, "📴 空闲状态(未从通话中转换)")
}
lastCallState = state
}
}
}
}
// 注册电话状态监听器
telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE)
isInitialized = true
Log.d(TAG, "✅ 电话状态监听器初始化成功")
} catch (e: SecurityException) {
Log.e(TAG, "❌ 电话状态监听器初始化失败 - 权限问题: ${e.message}")
} catch (e: Exception) {
Log.e(TAG, "❌ 电话状态监听器初始化失败: ${e.message}")
}
}
private fun hasPhoneStatePermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
}
private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.RECORD_AUDIO
) == PackageManager.PERMISSION_GRANTED
}
private fun initiateCall(phoneNumber: String, pipelineId: String? = null): Boolean {
if (!hasPhoneStatePermission()) {
Log.e(TAG, "❌ 没有权限拨打电话")
CallRecordManager.markCallAsFailed("缺少电话权限")
return false
}
// 重置通话状态
isCallInProgress = false
lastCallState = TelephonyManager.CALL_STATE_IDLE
// 通知 CallRecordManager 开始通话记录
CallRecordManager.startCallRecord(phoneNumber)
// 拨打电话
val intent = Intent(Intent.ACTION_CALL).apply {
data = android.net.Uri.parse("tel:$phoneNumber")
}
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
return try {
startActivity(intent)
Log.d(TAG, "✅ 拨号成功: $phoneNumber")
true
} catch (e: SecurityException) {
Log.e(TAG, "❌ 拨号失败,缺少 CALL_PHONE 权限: ${e.message}")
CallRecordManager.markCallAsFailed("缺少拨号权限: ${e.message}")
false
} catch (e: Exception) {
Log.e(TAG, "❌ 拨号失败: ${e.message}")
CallRecordManager.markCallAsFailed("拨号失败: ${e.message}")
false
}
}
// 自动拨号功能
private fun startAutoDial(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>) {
if (pipelines.isEmpty()) {
Log.e(TAG, "❌ 自动拨号启动失败:管道列表为空")
return
}
if (isAutoDialing) {
Log.w(TAG, "⚠️ 自动拨号已经在运行中")
return
}
isAutoDialing = true
pipelineList = pipelines
currentDialIndex = 0
Log.d(TAG, "🚀 开始自动拨号,共 ${pipelineList.size} 个号码")
// 重置通话状态
isCallInProgress = false
lastCallState = TelephonyManager.CALL_STATE_IDLE
// 开始第一个拨号
dialNextNumber()
}
// 新增:从指定位置开始自动拨号
private fun startAutoDialFromCurrent(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>, startIndex: Int) {
if (pipelines.isEmpty()) {
Log.e(TAG, "❌ 从指定位置自动拨号启动失败:管道列表为空")
return
}
// 如果已经在自动拨号,先停止
if (isAutoDialing) {
stopAutoDial()
}
isAutoDialing = true
pipelineList = pipelines
currentDialIndex = startIndex
Log.d(TAG, "🚀 从位置 $startIndex 开始自动拨号,共 ${pipelineList.size} 个号码")
// 重置通话状态
isCallInProgress = false
lastCallState = TelephonyManager.CALL_STATE_IDLE
// 从指定位置开始拨号
dialNextNumber()
}
// 新增:更新自动拨号索引
private fun updateAutoDialIndex(pipelines: List<com.example.initiateaphonecallapp.data.model.PipelineResponse>, currentIndex: Int) {
if (!isAutoDialing) {
Log.w(TAG, "⚠️ 自动拨号未运行,无法更新索引")
return
}
if (currentIndex < 0 || currentIndex >= pipelineList.size) {
Log.e(TAG, "❌ 更新自动拨号索引失败:索引 $currentIndex 超出范围")
return
}
// 更新管道列表和当前索引
pipelineList = pipelines
currentDialIndex = currentIndex
Log.d(TAG, "🔄 更新自动拨号索引为: $currentIndex")
// 取消当前的等待
autoDialRunnable?.let { handler.removeCallbacks(it) }
// 如果当前没有通话中,立即拨打新的号码
if (!isCallInProgress) {
dialNextNumber()
}
// 如果正在通话中,等待通话结束后会自动拨打新的索引位置的号码
}
private fun stopAutoDial() {
if (!isAutoDialing) {
Log.w(TAG, "⚠️ 自动拨号未运行")
return
}
isAutoDialing = false
autoDialRunnable?.let { handler.removeCallbacks(it) }
pipelineList = emptyList()
currentDialIndex = 0
isCallInProgress = false
lastCallState = TelephonyManager.CALL_STATE_IDLE
Log.d(TAG, "🛑 自动拨号已停止")
// 发送广播通知UI自动拨号已停止
sendBroadcast(Intent("AUTO_DIAL_STOPPED"))
}
private fun dialNextNumber() {
if (!isAutoDialing) {
return
}
// 检查是否所有号码都已拨打
if (currentDialIndex >= pipelineList.size) {
Log.d(TAG, "🎉 所有号码拨打完成")
stopAutoDial()
return
}
// 如果当前正在通话中,等待通话结束
if (isCallInProgress) {
Log.d(TAG, "⏳ 当前正在通话中,等待通话结束后继续")
return
}
val pipeline = pipelineList[currentDialIndex]
// 检查状态只有status为0或1才拨打
if (pipeline.status != 0 && pipeline.status != 1) {
Log.d(TAG, "⏭️ 跳过状态为 ${pipeline.status} 的客户: ${pipeline.name}")
currentDialIndex++
// 立即拨打下一个,不等待
handler.post {
dialNextNumber()
}
return
}
Log.d(TAG, "📞 自动拨打第 ${currentDialIndex + 1} 个号码: ${pipeline.name} - ${pipeline.phone} (状态: ${pipeline.status})")
// 通知UI当前拨打的索引
sendBroadcast(Intent("AUTO_DIAL_NEXT").apply {
putExtra("current_index", currentDialIndex)
})
val success = initiateCall(pipeline.phone, pipeline.id)
if (success) {
currentDialIndex++
// 如果拨打成功,等待通话结束(通话结束后会自动拨打下一个)
} else {
// 如果拨打失败10秒后尝试下一个
Log.w(TAG, "❌ 拨打失败10秒后尝试下一个")
currentDialIndex++
handler.postDelayed({
dialNextNumber()
}, AUTO_DIAL_INTERVAL)
}
}
override fun onDestroy() {
Log.d(TAG, "🔚 onDestroy()")
stopAutoDial()
// 正确取消注册电话状态监听器
try {
phoneStateListener?.let { listener ->
telephonyManager?.listen(listener, PhoneStateListener.LISTEN_NONE)
}
phoneStateListener = null
} catch (e: Exception) {
Log.e(TAG, "❌ 取消注册电话状态监听器失败: ${e.message}")
}
telephonyManager = null
isInitialized = false
super.onDestroy()
}
}

View File

@@ -0,0 +1,196 @@
package com.example.initiateaphonecallapp.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import androidx.lifecycle.MutableLiveData
import com.example.initiateaphonecallapp.data.model.WebSocketMessage
import com.example.initiateaphonecallapp.enums.MessageType
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import okhttp3.*
import okio.ByteString
import java.util.concurrent.TimeUnit
class WebSocketService : Service() {
private var webSocket: WebSocket? = null
private var currentToken: String? = null
private val client = OkHttpClient.Builder()
.readTimeout(3, TimeUnit.SECONDS)
.retryOnConnectionFailure(true)
.build()
// LiveData 用于观察 WebSocket 状态和消息
companion object {
val webSocketStatus = MutableLiveData<WebSocketStatus>()
val receivedMessages = MutableLiveData<WebSocketMessage>()
}
enum class WebSocketStatus {
CONNECTING, CONNECTED, DISCONNECTED, ERROR
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
// 不在 onCreate 中连接,等待 token
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.let {
when (it.action) {
"CONNECT" -> {
val token = it.getStringExtra("TOKEN")
token?.let { authToken ->
currentToken = authToken
connectWebSocket(authToken)
} ?: run {
println("❌ WebSocketService: 未提供 token无法连接")
}
}
"DISCONNECT" -> {
disconnectWebSocket()
}
"RECONNECT" -> {
val token = it.getStringExtra("TOKEN") ?: currentToken
token?.let { authToken ->
disconnectWebSocket()
connectWebSocket(authToken)
}
}
else -> {}
}
}
return START_STICKY
}
private fun connectWebSocket(token: String) {
// 构建带 token 的 WebSocket URL
val url = "ws://120.26.58.34/prod-api/ws/call?token=$token"
// val url = "ws://121.43.240.248/prod-api/ws/call?token=$token"
println("🔗 WebSocketService: 正在连接 WebSocket: $url")
val request = Request.Builder()
.url(url)
.build()
webSocketStatus.postValue(WebSocketStatus.CONNECTING)
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocketStatus.postValue(WebSocketStatus.CONNECTED)
println("✅ WebSocketService: 连接成功")
// 可以在这里发送额外的认证消息(如果需要)
// val authMessage = """{"type":"auth","token":"$token"}"""
// webSocket.send(authMessage)
}
override fun onMessage(webSocket: WebSocket, text: String) {
println("📨 WebSocketService: 收到消息: $text")
handleMessage(text)
}
override fun onMessage(webSocket: WebSocket, bytes: ByteString) {
handleMessage(bytes.utf8())
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
println("🔌 WebSocketService: 连接正在关闭: $reason")
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
println("🔌 WebSocketService: 连接已关闭: $reason")
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
webSocketStatus.postValue(WebSocketStatus.ERROR)
println("❌ WebSocketService: 连接失败: ${t.message}")
// 重连逻辑
CoroutineScope(Dispatchers.IO).launch {
Thread.sleep(5000)
currentToken?.let { token ->
println("🔄 WebSocketService: 尝试重新连接...")
connectWebSocket(token)
}
}
}
})
}
private fun handleMessage(message: String) {
try {
println("🔍 WebSocketService: 解析消息: $message")
val webSocketMessage = Gson().fromJson(message, WebSocketMessage::class.java)
receivedMessages.postValue(webSocketMessage)
// 处理电话号码消息
if (webSocketMessage.action == MessageType.CALL && webSocketMessage.phone != null) {
println("📞 WebSocketService: 接收到电话呼叫请求,号码: ${webSocketMessage.phone}")
handlePhoneCallMessage(webSocketMessage.phone!!)
} else {
println(" WebSocketService: 收到其他类型消息: ${webSocketMessage.action}")
}
} catch (e: Exception) {
println("❌ WebSocketService: 消息解析错误: ${e.message}")
e.printStackTrace()
}
}
private fun handlePhoneCallMessage(phoneNumber: String) {
println("📱 WebSocketService: 准备启动电话服务,号码: $phoneNumber")
try {
// 调用 PhoneCallService 发起电话
val intent = Intent(this, PhoneCallService::class.java).apply {
action = "ACTION_INITIATE_CALL"
putExtra("PHONE_NUMBER", phoneNumber)
}
println("📱 WebSocketService: 创建 Intent: $intent")
println("📱 WebSocketService: Intent action: ${intent.action}")
println("📱 WebSocketService: Intent extras: ${intent.extras}")
startService(intent)
println("✅ WebSocketService: 电话服务启动请求已发送")
} catch (e: Exception) {
println("❌ WebSocketService: 启动电话服务失败: ${e.message}")
e.printStackTrace()
}
}
private fun disconnectWebSocket() {
webSocket?.close(1000, "Manual disconnect")
// client.dispatcher.executorService.shutdown()
webSocketStatus.postValue(WebSocketStatus.DISCONNECTED)
println("🔌 WebSocketService: 手动断开连接")
}
// 发送消息到 WebSocket 服务器
fun sendMessage(message: String): Boolean {
return try {
webSocket?.send(message)
println("📤 WebSocketService: 发送消息: $message")
true
} catch (e: Exception) {
println("❌ WebSocketService: 发送消息失败: ${e.message}")
false
}
}
override fun onDestroy() {
disconnectWebSocket()
super.onDestroy()
}
}

View File

@@ -0,0 +1,220 @@
package com.example.initiateaphonecallapp.ui.LoginSms
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginSmsScreen(
viewModel: LoginSmsViewModel = viewModel(),
onLoginSuccess: (String) -> Unit = {},
onSwitchToPasswordLogin: () -> Unit = {} // 切换到密码登录的回调
) {
val loginState by viewModel.loginState.collectAsState()
// 监听登录成功状态
LaunchedEffect(loginState.isLoginSuccess) {
if (loginState.isLoginSuccess && loginState.authToken != null) {
onLoginSuccess(loginState.authToken!!)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
// Logo/标题区域
Icon(
painter = painterResource(id = android.R.drawable.ic_dialog_email),
contentDescription = "App Logo",
modifier = Modifier.size(80.dp),
tint = Color(0xFF10A37F)
)
Spacer(modifier = Modifier.height(40.dp))
// 标题
Text(
text = "验证码登录",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "请输入手机号获取验证码",
fontSize = 14.sp,
color = Color.Gray,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(40.dp))
// 手机号输入框
OutlinedTextField(
value = loginState.phone,
onValueChange = viewModel::onPhoneChange,
label = { Text("手机号") },
placeholder = { Text("请输入您的手机号") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(0xFF10A37F),
unfocusedBorderColor = Color(0xFFE5E5E5),
focusedTextColor = Color.Black,
focusedPlaceholderColor = Color(0xFF999999),
unfocusedTextColor = Color.Black,
unfocusedPlaceholderColor = Color(0xFF999999)
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone)
)
Spacer(modifier = Modifier.height(20.dp))
// 验证码输入框和获取验证码按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// 验证码输入框
OutlinedTextField(
value = loginState.code,
onValueChange = viewModel::onCodeChange,
label = { Text("验证码") },
placeholder = { Text("请输入验证码") },
singleLine = true,
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(0xFF10A37F),
unfocusedBorderColor = Color(0xFFE5E5E5),
focusedTextColor = Color.Black,
focusedPlaceholderColor = Color(0xFF999999),
unfocusedTextColor = Color.Black,
unfocusedPlaceholderColor = Color(0xFF999999)
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
// 获取验证码按钮
val countdown = loginState.countdown
val isGetCodeLoading = loginState.isGetCodeLoading
Button(
onClick = { viewModel.getCode() },
modifier = Modifier
.width(100.dp)
.height(56.dp)
.clip(RoundedCornerShape(12.dp)),
colors = ButtonDefaults.buttonColors(
containerColor = if (countdown > 0) Color(0xFFCCCCCC) else Color(0xFF10A37F),
disabledContainerColor = Color(0xFFCCCCCC)
),
enabled = countdown == 0 && !isGetCodeLoading
) {
if (isGetCodeLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = if (countdown > 0) "${countdown}s" else "获取验证码",
fontSize = 12.sp,
fontWeight = FontWeight.Medium
)
}
}
}
// 错误信息显示
if (loginState.errorMessage.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = loginState.errorMessage,
color = Color.Red,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(40.dp))
// 登录按钮
Button(
onClick = {
viewModel.loginSms()
},
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.clip(RoundedCornerShape(12.dp)),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF10A37F),
disabledContainerColor = Color(0xFFCCCCCC)
),
enabled = !loginState.isLoading
) {
if (loginState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = "登录",
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(20.dp))
// 切换到密码登录
TextButton(
onClick = onSwitchToPasswordLogin
) {
Text(
text = "使用密码登录",
fontSize = 14.sp,
color = Color(0xFF10A37F)
)
}
}
}
}

View File

@@ -0,0 +1,12 @@
package com.example.initiateaphonecallapp.ui.LoginSms
data class LoginSmsState(
val phone: String = "",
val code: String = "",
val isLoading: Boolean = false,
val isGetCodeLoading: Boolean = false,
val countdown: Int = 0, // 倒计时秒数
val isLoginSuccess: Boolean = false,
val authToken: String? = null,
val errorMessage: String = ""
)

View File

@@ -0,0 +1,199 @@
// ui/login/LoginSmsViewModel.kt
package com.example.initiateaphonecallapp.ui.LoginSms
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.repository.AuthRepository
import com.example.initiateaphonecallapp.manager.PreferenceManager
import com.example.initiateaphonecallapp.manager.TokenManager
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class LoginSmsViewModel(
private val authRepository: AuthRepository = AuthRepository()
) : ViewModel() {
private val _loginState = MutableStateFlow(LoginSmsState())
val loginState: StateFlow<LoginSmsState> = _loginState.asStateFlow()
private var countdownJob: Job? = null
fun onPhoneChange(phone: String) {
_loginState.value = _loginState.value.copy(
phone = phone,
errorMessage = ""
)
}
fun onCodeChange(code: String) {
_loginState.value = _loginState.value.copy(
code = code,
errorMessage = ""
)
}
// 获取验证码
fun getCode() {
val currentState = _loginState.value
if (currentState.phone.isEmpty()) {
_loginState.value = currentState.copy(
errorMessage = "请输入手机号"
)
return
}
if (!isValidPhone(currentState.phone)) {
_loginState.value = currentState.copy(
errorMessage = "请输入正确的手机号"
)
return
}
if (currentState.countdown > 0) {
return // 正在倒计时,不能重复获取
}
_loginState.value = currentState.copy(
isGetCodeLoading = true,
errorMessage = ""
)
viewModelScope.launch {
val result = authRepository.getCode(currentState.phone)
result.fold(
onSuccess = {
_loginState.value = currentState.copy(
isGetCodeLoading = false,
errorMessage = ""
)
startCountdown()
},
onFailure = { exception ->
_loginState.value = currentState.copy(
isGetCodeLoading = false,
errorMessage = exception.message ?: "获取验证码失败"
)
}
)
}
}
// 验证码登录
fun loginSms() {
val currentState = _loginState.value
if (currentState.phone.isEmpty() || currentState.code.isEmpty()) {
_loginState.value = currentState.copy(
errorMessage = "请填写手机号和验证码"
)
return
}
if (!isValidPhone(currentState.phone)) {
_loginState.value = currentState.copy(
errorMessage = "请输入正确的手机号"
)
return
}
if (currentState.code.length != 6) {
_loginState.value = currentState.copy(
errorMessage = "验证码必须是6位数字"
)
return
}
_loginState.value = currentState.copy(
isLoading = true,
errorMessage = ""
)
viewModelScope.launch {
val result = authRepository.loginSms(
phone = currentState.phone,
code = currentState.code
)
result.fold(
onSuccess = { loginResponse ->
if (loginResponse.code == 200) {
// 登录成功
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = true,
authToken = loginResponse.token,
errorMessage = ""
)
// 保存登录信息只保存token不保存密码
loginResponse.token?.let { token ->
PreferenceManager.saveLoginInfo(
username = currentState.phone, // 使用手机号作为用户名
password = "", // 验证码登录不需要密码
token = token,
rememberMe = true, // 验证码登录默认记住
autoLogin = false // 验证码登录默认不自动登录
)
}
TokenManager.setToken(loginResponse.token!!)
} else {
// 登录失败
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = false,
authToken = null,
errorMessage = loginResponse.msg ?: "登录失败"
)
}
},
onFailure = { exception ->
// 网络请求失败
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = false,
authToken = null,
errorMessage = exception.message ?: "网络请求失败"
)
}
)
}
}
// 开始倒计时
private fun startCountdown() {
countdownJob?.cancel()
countdownJob = viewModelScope.launch {
var count = 60
while (count > 0) {
_loginState.value = _loginState.value.copy(countdown = count)
delay(1000)
count--
}
_loginState.value = _loginState.value.copy(countdown = 0)
}
}
// 验证手机号格式
private fun isValidPhone(phone: String): Boolean {
return phone.matches(Regex("^1[3-9]\\d{9}$"))
}
// 清理资源
override fun onCleared() {
super.onCleared()
countdownJob?.cancel()
}
// 清除状态
fun clearState() {
countdownJob?.cancel()
_loginState.value = LoginSmsState()
}
}

View File

@@ -0,0 +1,108 @@
// ui/calllog/AddContactFromCallDialog.kt
package com.example.initiateaphonecallapp.ui.calllog
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddContactFromCallDialog(
phoneNumber: String,
onDismiss: () -> Unit,
onConfirm: (name: String, phone: String) -> Unit,
modifier: Modifier = Modifier
) {
var contactName by remember { mutableStateOf("") }
var contactPhone by remember { mutableStateOf(phoneNumber) }
val focusRequester = remember { FocusRequester() }
val coroutineScope = rememberCoroutineScope()
// 自动聚焦到姓名输入框
LaunchedEffect(Unit) {
delay(100)
focusRequester.requestFocus()
}
Dialog(
onDismissRequest = onDismiss
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "添加联系人",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(bottom = 16.dp)
)
// 姓名输入框
TextField(
value = contactName,
onValueChange = { contactName = it },
label = { Text("姓名") },
placeholder = { Text("请输入联系人姓名") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.padding(bottom = 16.dp)
)
// 电话输入框
TextField(
value = contactPhone,
onValueChange = { contactPhone = it },
label = { Text("电话") },
placeholder = { Text("请输入联系人电话") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
)
// 按钮区域
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(
onClick = onDismiss,
modifier = Modifier.padding(end = 8.dp)
) {
Text("取消")
}
Button(
onClick = {
if (contactName.isNotBlank() && contactPhone.isNotBlank()) {
onConfirm(contactName, contactPhone)
}
},
enabled = contactName.isNotBlank() && contactPhone.isNotBlank()
) {
Text("保存")
}
}
}
}
}
}

View File

@@ -0,0 +1,557 @@
// ui/calllog/CallLogScreen.kt
package com.example.initiateaphonecallapp.ui.calllog
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.toCallRecord
import com.example.initiateaphonecallapp.enums.CallStatus
import com.example.initiateaphonecallapp.enums.CallStatus.*
import com.example.initiateaphonecallapp.ui.contact.ContactsViewModel
import com.example.initiateaphonecallapp.utils.TimeRangeUtils
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CallLogScreen(
viewModel: CallLogViewModel = viewModel(),
contactsViewModel: ContactsViewModel = viewModel(),
modifier: Modifier = Modifier,
onCallRecordClick: (CallRecord) -> Unit = {},
onCallBack: (String) -> Unit = {},
onPlayAudio: (String?) -> Unit = {}
) {
val callRecords by viewModel.callRecords.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val selectedTimeRange by viewModel.selectedTimeRange.collectAsState()
// 新增:音频播放相关状态
val currentlyPlayingAudio by viewModel.currentlyPlayingAudio.collectAsState()
val playbackError by viewModel.playbackError.collectAsState()
// 添加联系人相关状态
var showAddContactDialog by remember { mutableStateOf(false) }
var selectedPhoneNumber by remember { mutableStateOf("") }
var showSuccessDialog by remember { mutableStateOf(false) } // 新增:成功提示框
var successMessage by remember { mutableStateOf("") } // 新增:成功消息
val context = LocalContext.current
// 监听播放错误
LaunchedEffect(playbackError) {
playbackError?.let { error ->
// 可以在这里显示错误提示,比如使用 Snackbar
println("播放错误: $error")
// 或者显示一个错误对话框
}
}
// 监听添加联系人结果
val addContactResult by contactsViewModel.addContactResult.collectAsState()
LaunchedEffect(addContactResult) {
addContactResult?.let { result ->
if (result.code == 200) {
// 显示成功提示
successMessage = result.msg.takeIf { it.isNotBlank() } ?: "联系人添加成功"
showSuccessDialog = true
contactsViewModel.clearAddContactResult()
showAddContactDialog = false
}
}
}
// 监听添加联系人错误
val addContactError by contactsViewModel.errorMessage.collectAsState()
LaunchedEffect(addContactError) {
addContactError?.let { error ->
// 可以在这里显示错误提示
contactsViewModel.clearError()
}
}
// 初始加载全部数据
LaunchedEffect(Unit) {
viewModel.loadCallRecords("全部")
}
Scaffold(
snackbarHost = {
// 可以在这里添加 Snackbar 来显示播放错误
SnackbarHost(hostState = remember { SnackbarHostState() })
},
topBar = {
CallLogTopBar(
selectedTimeRange = selectedTimeRange,
onTimeRangeSelected = { range ->
val request = when (range) {
"今天" -> TimeRangeUtils.createTodayRequest()
"本周" -> TimeRangeUtils.createWeekRequest()
"本月" -> TimeRangeUtils.createMonthRequest()
else -> null
}
viewModel.loadCallRecords(range, request)
}
)
},
floatingActionButton = {
if (errorMessage != null) {
ExtendedFloatingActionButton(
onClick = { viewModel.refresh() },
icon = { Icon(Icons.Default.Refresh, "刷新") },
text = { Text("刷新") }
)
}
}
) { innerPadding ->
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
ErrorState(
message = errorMessage!!,
onRetry = { viewModel.refresh() },
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
)
}
else -> {
CallLogContent(
callRecords = callRecords,
onCallRecordClick = onCallRecordClick,
onCallBack = onCallBack,
onPlayAudio = { audioUrl ->
audioUrl?.let {
viewModel.playAudio(it)
}
},
onAddContact = { phoneNumber ->
selectedPhoneNumber = phoneNumber
showAddContactDialog = true
},
currentlyPlayingAudio = currentlyPlayingAudio, // 传递当前播放状态
modifier = Modifier.padding(innerPadding)
)
}
}
// 添加联系人对话框
if (showAddContactDialog) {
AddContactFromCallDialog(
phoneNumber = selectedPhoneNumber,
onDismiss = { showAddContactDialog = false },
onConfirm = { name, phone ->
contactsViewModel.addContact(name, phone)
}
)
}
// 成功提示对话框
if (showSuccessDialog) {
SuccessDialog(
message = successMessage,
onDismiss = { showSuccessDialog = false }
)
}
}
}
// 成功提示对话框组件
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SuccessDialog(
message: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier
) {
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.CheckCircle,
contentDescription = "成功",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 8.dp)
)
Text("操作成功")
}
},
text = {
Text(message)
},
confirmButton = {
Button(onClick = onDismiss) {
Text("确定")
}
}
)
}
// 其他组件保持不变CallLogTopBar、ErrorState、CallStatistics、StatItem、DateGroupHeader、CallStatusIcon等
// 只需要确保删除了重复的 CallLogContent 函数
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun CallLogContent(
callRecords: List<CallRecord>,
onCallRecordClick: (CallRecord) -> Unit,
onCallBack: (String) -> Unit,
onPlayAudio: (String?) -> Unit,
onAddContact: (String) -> Unit, // 确保这个参数存在
currentlyPlayingAudio: String?, // 新增当前播放的音频URL
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
// 统计信息
if (callRecords.isNotEmpty()) {
CallStatistics(
callRecords = callRecords,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
if (callRecords.isEmpty()) {
EmptyCallLog(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
)
} else {
val groupedRecords = callRecords.groupBy { record ->
when {
isToday(record.startTime) -> "今天"
isYesterday(record.startTime) -> "昨天"
isThisWeek(record.startTime) -> "本周"
isThisMonth(record.startTime) -> "本月"
else -> "更早"
}
}
LazyColumn {
groupedRecords.forEach { (dateGroup, records) ->
stickyHeader {
DateGroupHeader(
dateGroup = dateGroup,
recordCount = records.size,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
items(records) { record ->
CallRecordItem(
record = record,
onCallRecordClick = { onCallRecordClick(record) },
onCallBack = { onCallBack(record.phoneNumber) },
onPlayAudio = { onPlayAudio(record.audioFileUri) },
onAddContact = { onAddContact(record.phoneNumber) }, // 传递电话号码
isPlaying = record.audioFileUri == currentlyPlayingAudio, // 传递播放状态
modifier = Modifier.padding(horizontal = 16.dp, vertical = 2.dp)
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CallLogTopBar(
selectedTimeRange: String,
onTimeRangeSelected: (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val timeRanges = listOf("全部", "今天", "本周", "本月")
CenterAlignedTopAppBar(
title = {
Text("通话记录", fontWeight = FontWeight.Bold)
},
actions = {
Box {
TextButton(onClick = { expanded = true }) {
Text(selectedTimeRange)
Icon(Icons.Default.ArrowDropDown, contentDescription = "选择时间范围")
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
timeRanges.forEach { range ->
DropdownMenuItem(
text = { Text(range) },
onClick = {
onTimeRangeSelected(range)
expanded = false
}
)
}
}
}
}
)
}
@Composable
fun ErrorState(
message: String,
onRetry: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = modifier
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = "错误",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(64.dp)
)
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
Button(onClick = onRetry) {
Text("重试")
}
}
}
@Composable
fun CallStatistics(
callRecords: List<CallRecord>,
modifier: Modifier = Modifier
) {
val completedCount = callRecords.count { it.status == COMPLETED }
val failedCount = callRecords.count { it.status == FAILED }
val totalDuration = callRecords.sumOf { it.duration }
val hasRecordings = callRecords.count { it.audioFileUri != null }
Card(
modifier = modifier
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
"通话统计",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 8.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
StatItem("成功", completedCount.toString())
StatItem("失败", failedCount.toString())
StatItem("录音", hasRecordings.toString())
StatItem("总时长", "${totalDuration / 60}分钟 ${totalDuration % 60}")
}
}
}
}
@Composable
fun StatItem(label: String, value: String, modifier: Modifier = Modifier) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
value,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary
)
Text(
label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun DateGroupHeader(
dateGroup: String,
recordCount: Int,
modifier: Modifier = Modifier
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = modifier
) {
Text(
dateGroup,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
Text(
"$recordCount 条记录",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun CallStatusIcon(
status: CallStatus,
modifier: Modifier = Modifier
) {
val (icon, tint) = when (status) {
COMPLETED -> Icons.Default.CheckCircle to MaterialTheme.colorScheme.primary
FAILED -> Icons.Default.Info to MaterialTheme.colorScheme.error
INITIATED -> Icons.Default.Phone to MaterialTheme.colorScheme.secondary
DIALING -> TODO()
CONNECTED -> TODO()
ENDED -> TODO()
NOT_CONNECTED -> TODO()
}
Icon(
imageVector = icon,
contentDescription = status.name,
tint = tint,
modifier = modifier
)
}
fun getStatusDisplayText(status: CallStatus): String {
return when (status) {
COMPLETED -> "成功"
FAILED -> "失败"
INITIATED -> "进行中"
DIALING -> TODO()
CONNECTED -> TODO()
ENDED -> TODO()
NOT_CONNECTED -> TODO()
}
}
@Composable
fun getStatusColor(status: CallStatus): androidx.compose.ui.graphics.Color {
return when (status) {
COMPLETED -> MaterialTheme.colorScheme.primary
FAILED -> MaterialTheme.colorScheme.error
INITIATED -> MaterialTheme.colorScheme.secondary
DIALING -> TODO()
CONNECTED -> TODO()
ENDED -> TODO()
NOT_CONNECTED -> TODO()
}
}
@Composable
fun EmptyCallLog(
modifier: Modifier = Modifier
) {
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "无通话记录",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(64.dp)
)
Text(
text = "暂无通话记录",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
}
}
}
// 时间格式化辅助函数
fun formatTimeOnly(date: Date): String {
return SimpleDateFormat("HH:mm", Locale.getDefault()).format(date)
}
// 时间分组辅助函数
private fun isToday(date: Date): Boolean {
val today = Calendar.getInstance()
val target = Calendar.getInstance().apply { time = date }
return today.get(Calendar.YEAR) == target.get(Calendar.YEAR) &&
today.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR)
}
private fun isYesterday(date: Date): Boolean {
val yesterday = Calendar.getInstance().apply { add(Calendar.DAY_OF_YEAR, -1) }
val target = Calendar.getInstance().apply { time = date }
return yesterday.get(Calendar.YEAR) == target.get(Calendar.YEAR) &&
yesterday.get(Calendar.DAY_OF_YEAR) == target.get(Calendar.DAY_OF_YEAR)
}
private fun isThisWeek(date: Date): Boolean {
val weekStart = Calendar.getInstance().apply {
set(Calendar.DAY_OF_WEEK, firstDayOfWeek)
}
val target = Calendar.getInstance().apply { time = date }
return !target.before(weekStart) && !isToday(date) && !isYesterday(date)
}
private fun isThisMonth(date: Date): Boolean {
val monthStart = Calendar.getInstance().apply { set(Calendar.DAY_OF_MONTH, 1) }
val target = Calendar.getInstance().apply { time = date }
return !target.before(monthStart) && !isThisWeek(date) && !isToday(date) && !isYesterday(date)
}

View File

@@ -0,0 +1,140 @@
package com.example.initiateaphonecallapp.ui.calllog
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import com.example.initiateaphonecallapp.data.repository.CallLogRepository
import com.example.initiateaphonecallapp.manager.AudioPlayManager
import com.example.initiateaphonecallapp.utils.TimeRangeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.time.LocalDateTime
class CallLogViewModel(private val callLogRepository: CallLogRepository = CallLogRepository()
) : ViewModel() {
private val _callRecords = MutableStateFlow<List<CallRecord>>(emptyList())
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
private val _selectedTimeRange = MutableStateFlow("全部")
val selectedTimeRange: StateFlow<String> = _selectedTimeRange.asStateFlow()
// 新增:音频播放相关状态
private val _currentlyPlayingAudio = MutableStateFlow<String?>(null)
val currentlyPlayingAudio: StateFlow<String?> = _currentlyPlayingAudio.asStateFlow()
private val _playbackError = MutableStateFlow<String?>(null)
val playbackError: StateFlow<String?> = _playbackError.asStateFlow()
/**
* 播放音频
*/
fun playAudio(audioUrl: String) {
_playbackError.value = null
// 如果正在播放同一个音频,则停止播放
if (AudioPlayManager.isPlaying(audioUrl)) {
stopAudio()
return
}
AudioPlayManager.playAudio(
audioUrl = audioUrl,
onStart = {
_currentlyPlayingAudio.value = audioUrl
},
onCompletion = {
_currentlyPlayingAudio.value = null
},
onError = { error ->
_playbackError.value = error
_currentlyPlayingAudio.value = null
}
)
}
/**
* 停止音频播放
*/
fun stopAudio() {
AudioPlayManager.stopAudio()
_currentlyPlayingAudio.value = null
_playbackError.value = null
}
/**
* 暂停音频播放
*/
fun pauseAudio() {
AudioPlayManager.pauseAudio()
_currentlyPlayingAudio.value = null
}
/**
* 检查是否正在播放指定音频
*/
fun isAudioPlaying(audioUrl: String): Boolean {
return AudioPlayManager.isPlaying(audioUrl)
}
/**
* 清除播放错误
*/
fun clearPlaybackError() {
_playbackError.value = null
}
override fun onCleared() {
super.onCleared()
// 清理资源
AudioPlayManager.stopAudio()
}
/**
* 加载通话记录
* @param timeRange 时间范围描述用于UI显示
* @param request 实际的请求参数
*/
fun loadCallRecords(timeRange: String = "全部", request: CallRecordRequest? = null) {
_isLoading.value = true
_errorMessage.value = null
_selectedTimeRange.value = timeRange
viewModelScope.launch {
// 如果没有传入request使用默认的空参数获取全部
val actualRequest = request ?: TimeRangeUtils.createWeekRequest()
val result = callLogRepository.getCallLogs(actualRequest)
result
.onSuccess { callRecords ->
_callRecords.value = callRecords
}
.onFailure { exception ->
_errorMessage.value = exception.message ?: "未知错误"
}
_isLoading.value = false
}
}
/**
* 刷新数据(使用相同的请求参数)
*/
fun refresh() {
loadCallRecords(_selectedTimeRange.value)
}
/**
* 清除错误状态
*/
fun clearError() {
_errorMessage.value = null
}
}

View File

@@ -0,0 +1,144 @@
// ui/calllog/CallRecordItem.kt (新增文件)
package com.example.initiateaphonecallapp.ui.calllog
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.unit.dp
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.enums.CallStatus
@OptIn(ExperimentalMaterial3Api::class)
// ui/calllog/CallRecordItem.kt (更新版本)
@Composable
fun CallRecordItem(
record: CallRecord,
onCallRecordClick: () -> Unit,
onCallBack: () -> Unit,
onPlayAudio: () -> Unit,
onAddContact: () -> Unit,
isPlaying: Boolean = false, // 新增:播放状态
modifier: Modifier = Modifier
) {
Card(
onClick = onCallRecordClick,
modifier = modifier
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 通话状态图标
CallStatusIcon(
status = record.status,
modifier = Modifier.padding(end = 16.dp)
)
// 通话信息
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = record.phoneNumber,
style = MaterialTheme.typography.titleMedium,
maxLines = 1
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(top = 4.dp)
) {
Text(
text = getStatusDisplayText(record.status),
style = MaterialTheme.typography.bodySmall,
color = getStatusColor(record.status)
)
Text(
text = record.formattedDuration,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// 录音标识
if (record.audioFileUri != null) {
Text(
text = "有录音",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
}
// 时间信息
Text(
text = record.formattedStartTime,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
// 操作按钮区域
Column(
horizontalAlignment = Alignment.End
) {
Text(
text = formatTimeOnly(record.startTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Row(
modifier = Modifier.padding(top = 4.dp)
) {
// 添加联系人按钮
IconButton(
onClick = onAddContact
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "添加联系人",
tint = MaterialTheme.colorScheme.primary
)
}
// 播放录音按钮
if (record.audioFileUri != null) {
IconButton(
onClick = onPlayAudio
) {
Icon(
imageVector = if (isPlaying) Icons.Default.PlayArrow else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "暂停播放" else "播放录音",
tint = if (isPlaying) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary
)
}
}
// 回拨按钮
IconButton(
onClick = onCallBack
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "回拨",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
}

View File

@@ -0,0 +1,129 @@
// MainScreen.kt
package com.example.initiateaphonecallapp.ui
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.DateRange
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.List
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import com.example.initiateaphonecallapp.service.PhoneCallService
import com.example.initiateaphonecallapp.ui.calllog.CallLogScreen
import com.example.initiateaphonecallapp.ui.contact.ContactScreen
import com.example.initiateaphonecallapp.ui.home.HomeScreen
import com.example.initiateaphonecallapp.ui.pipeline.PipelineScreen
import com.example.initiateaphonecallapp.ui.profile.ProfileScreen
sealed class BottomNavItem(val route: String, val title: String, val icon: @Composable () -> Unit) {
object Home : BottomNavItem("home", "系统主页", {
Icon(Icons.Default.Home, contentDescription = "主页")
})
object Pipeline : BottomNavItem("pipeline", "客户管道", {
Icon(Icons.Default.List, contentDescription = "客户管道")
})
object CallLog : BottomNavItem("call_log", "通话记录", {
Icon(Icons.Default.Call, contentDescription = "通话记录")
})
object Contact : BottomNavItem("contact", "联系人", {
Icon(Icons.Default.Person, contentDescription = "联系人")
})
// 在 BottomNavItem sealed class 中添加
object Profile : BottomNavItem("profile", "个人中心", {
Icon(Icons.Default.Person, contentDescription = "个人中心")
})
}
@Composable
fun MainScreen(authToken: String, onLogout: () -> Unit) {
var currentScreen by rememberSaveable { mutableStateOf(BottomNavItem.Home.route) }
val context = LocalContext.current
Scaffold(
bottomBar = {
NavigationBar {
listOf(
BottomNavItem.Home,
// BottomNavItem.Pipeline,
BottomNavItem.CallLog,
BottomNavItem.Contact,
BottomNavItem.Profile // 添加个人中心
).forEach { item ->
NavigationBarItem(
icon = { item.icon() },
label = { Text(item.title) },
selected = currentScreen == item.route,
onClick = {
currentScreen = item.route
}
)
}
}
}
) { innerPadding ->
when (currentScreen) {
BottomNavItem.Home.route -> HomeScreen(
authToken = authToken,
onLogout = onLogout,
modifier = Modifier.padding(innerPadding)
)
BottomNavItem.Pipeline.route -> PipelineScreen(
modifier = Modifier.padding(innerPadding)
)
BottomNavItem.CallLog.route -> CallLogScreen(
modifier = Modifier.padding(innerPadding),
onCallRecordClick = { record ->
// 处理通话记录点击事件
println("点击通话记录: ${record.phoneNumber}, 状态: ${record.status}")
},
onCallBack = { phoneNumber ->
// 处理回拨功能
println("回拨号码: $phoneNumber")
initiatePhoneCall(context, phoneNumber)
},
onPlayAudio = { audioUrl ->
// 处理播放录音
println("播放录音: $audioUrl")
// 这里可以添加播放音频的逻辑
}
)
BottomNavItem.Contact.route -> ContactScreen(
modifier = Modifier.padding(innerPadding),
onContactClick = { contact ->
// 处理联系人点击事件(查看详情等)
println("点击联系人: ${contact.name}, 电话: ${contact.phone}")
},
onCallContact = { phoneNumber ->
// 使用 PhoneCallService 拨打电话
initiatePhoneCall(context, phoneNumber)
}
)
// 在 when 语句中添加 Profile 路由
BottomNavItem.Profile.route -> ProfileScreen(
modifier = Modifier.padding(innerPadding)
)
}
}
}
/**
* 启动电话拨号服务
*/
private fun initiatePhoneCall(context: Context, phoneNumber: String) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_INITIATE_CALL"
putExtra("PHONE_NUMBER", phoneNumber)
}
ContextCompat.startForegroundService(context, intent)
}

View File

@@ -0,0 +1,264 @@
// ui/contact/ContactScreen.kt
package com.example.initiateaphonecallapp.ui.contact
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
import com.example.initiateaphonecallapp.service.PhoneCallService
import com.example.initiateaphonecallapp.ui.calllog.AddContactFromCallDialog
import com.example.initiateaphonecallapp.ui.calllog.SuccessDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactScreen(
viewModel: ContactsViewModel = viewModel(),
modifier: Modifier = Modifier,
onContactClick: (ApiContactsRequest) -> Unit = {},
onCallContact: (String) -> Unit = {}
) {
val contacts by viewModel.contacts.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val context = LocalContext.current
var showAddContactDialog by remember { mutableStateOf(false) }
var showSuccessDialog by remember { mutableStateOf(false) } // 新增:成功提示框
var successMessage by remember { mutableStateOf("") } // 新增:成功消息
// 监听添加联系人结果
val addContactResult by viewModel.addContactResult.collectAsState()
LaunchedEffect(addContactResult) {
addContactResult?.let { result ->
if (result.code == 200) {
// 显示成功提示
successMessage = result.msg.takeIf { it.isNotBlank() } ?: "联系人添加成功"
showSuccessDialog = true
viewModel.clearAddContactResult()
showAddContactDialog = false
viewModel.loadContacts()
}
}
}
// 初始加载联系人数据
LaunchedEffect(Unit) {
viewModel.loadContacts()
}
// 添加联系人对话框
if (showAddContactDialog) {
AddContactFromCallDialog(
phoneNumber = "",
onDismiss = { showAddContactDialog = false },
onConfirm = { name, phone ->
viewModel.addContact(name, phone)
}
)
}
// 成功提示对话框
if (showSuccessDialog) {
SuccessDialog(
message = successMessage,
onDismiss = { showSuccessDialog = false }
)
}
Scaffold(
topBar = {
CenterAlignedTopAppBar(
title = { Text("联系人") },
actions = {
IconButton(onClick = { showAddContactDialog=true }) {
Icon(imageVector = Icons.Default.Add, contentDescription = "添加")
}
}
)
},
floatingActionButton = {
if (errorMessage != null) {
ExtendedFloatingActionButton(
onClick = { viewModel.loadContacts() },
icon = { Icon(Icons.Default.Refresh, "刷新") },
text = { Text("刷新") }
)
}
}
) { innerPadding ->
Column(
modifier = modifier
.fillMaxSize()
.padding(innerPadding)
) {
when {
isLoading -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
// errorMessage != null -> {
// Box(
// modifier = Modifier
// .fillMaxSize()
// .padding(16.dp),
// contentAlignment = Alignment.Center
// ) {
// Column(
// horizontalAlignment = Alignment.CenterHorizontally
// ) {
// Text(
// text = errorMessage!!,
// style = MaterialTheme.typography.bodyMedium,
// color = MaterialTheme.colorScheme.error
// )
// Button(
// onClick = { viewModel.loadContacts() },
// modifier = Modifier.padding(top = 16.dp)
// ) {
// Text("重试")
// }
// }
// }
// }
contacts.isEmpty() -> {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "暂无联系人",
style = MaterialTheme.typography.bodyLarge
)
}
}
else -> {
LazyColumn {
items(contacts) { contact ->
ContactItem(
contact = contact,
onContactClick = { onContactClick(contact) },
onCallContact = onCallContact,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp)
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ContactItem(
contact: ApiContactsRequest,
onContactClick: (ApiContactsRequest) -> Unit,
onCallContact: (String) -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
Card(
onClick = { onContactClick(contact) },
modifier = modifier
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 第一行:联系人信息和拨号按钮
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// 联系人信息
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = "联系人",
modifier = Modifier.padding(end = 12.dp)
)
Column {
Text(
text = contact.name,
style = MaterialTheme.typography.titleMedium
)
Text(
text = contact.phone,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
// 拨号按钮
IconButton(
onClick = {
onCallContact(contact.phone)
initiatePhoneCall(context, contact.phone)
}
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = "拨打电话",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
/**
* 启动电话拨号服务
*/
fun initiatePhoneCall(context: Context, phoneNumber: String) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_INITIATE_CALL"
putExtra("PHONE_NUMBER", phoneNumber)
}
ContextCompat.startForegroundService(context, intent)
}

View File

@@ -0,0 +1,102 @@
// ui/contact/ContactsViewModel.kt
package com.example.initiateaphonecallapp.ui.contact
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.ApiContactsRequest
import com.example.initiateaphonecallapp.data.model.ApiContactsResponse
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import com.example.initiateaphonecallapp.data.repository.ContactsRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class ContactsViewModel(
) : ViewModel() {
private val contactsRepository = ContactsRepository()
private val _addContactResult = MutableStateFlow<ApiContactsResponse?>(null)
val addContactResult: StateFlow<ApiContactsResponse?> = _addContactResult.asStateFlow()
private val _contacts = MutableStateFlow<List<ApiContactsRequest>>(emptyList())
val contacts: StateFlow<List<ApiContactsRequest>> = _contacts.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
/**
* 添加联系人
* @param name 联系人姓名
* @param phone 联系人电话
*/
fun addContact(name: String, phone: String) {
_isLoading.value = true
_errorMessage.value = null
_addContactResult.value = null
viewModelScope.launch {
val result = contactsRepository.addContact(name, phone)
result
.onSuccess { response ->
_addContactResult.value = response
if (response.code==200) {
_errorMessage.value = response.msg
}
}
.onFailure { exception ->
_errorMessage.value = exception.message ?: "添加联系人失败"
}
_isLoading.value = false
}
}
/**
* 加载联系人列表
*/
fun loadContacts() {
_isLoading.value = true
_errorMessage.value = null
viewModelScope.launch {
val result = contactsRepository.listContacts()
result
.onSuccess { contactsList ->
_contacts.value = contactsList.data
}
.onFailure { exception ->
_errorMessage.value = exception.message ?: "加载联系人失败"
}
_isLoading.value = false
}
}
/**
* 清除添加联系人的结果
*/
fun clearAddContactResult() {
_addContactResult.value = null
}
/**
* 清除错误消息
*/
fun clearError() {
_errorMessage.value = null
}
/**
* 清除所有状态
*/
fun clearAll() {
_addContactResult.value = null
_errorMessage.value = null
}
}

View File

@@ -0,0 +1,461 @@
package com.example.initiateaphonecallapp.ui.home
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.manager.CallRecordManager
import com.example.initiateaphonecallapp.service.PhoneCallService
import com.example.initiateaphonecallapp.service.WebSocketService
import com.example.initiateaphonecallapp.ui.theme.InitiateAPhoneCallAppTheme
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(
authToken: String,
viewModel: HomeViewModel = viewModel(),
onLogout: () -> Unit = {},
modifier: Modifier
) {
val context = LocalContext.current
val callRecords by viewModel.callRecords.collectAsState()
val currentCall by viewModel.currentCall.collectAsState()
val recentCall by viewModel.recentCall.collectAsState()
val webSocketStatus by viewModel.webSocketStatus.collectAsState()
val receivedMessages by viewModel.receivedMessages.collectAsState()
val uploadStatus by viewModel.uploadStatus.collectAsState()
val autoScanStatus by viewModel.autoScanStatus.collectAsState()
// 启动服务
LaunchedEffect(authToken) {
println("🏠 HomeScreen: 启动服务token: ${authToken.take(20)}...")
val webSocketIntent = Intent(context, WebSocketService::class.java).apply {
action = "CONNECT"
putExtra("TOKEN", authToken)
}
context.startService(webSocketIntent)
// 启动电话服务
context.startService(Intent(context, PhoneCallService::class.java))
println("🏠 HomeScreen: 服务启动完成")
}
InitiateAPhoneCallAppTheme {
Scaffold(
topBar = {
TopAppBar(
title = { Text("电话呼叫系统") },
actions = {
IconButton(onClick = onLogout) {
Text("退出")
}
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
) {
// WebSocket 状态显示
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(
color = when (webSocketStatus) {
WebSocketService.WebSocketStatus.CONNECTED -> androidx.compose.ui.graphics.Color.Green
WebSocketService.WebSocketStatus.CONNECTING -> androidx.compose.ui.graphics.Color.Yellow
else -> androidx.compose.ui.graphics.Color.Red
},
shape = androidx.compose.foundation.shape.CircleShape
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (webSocketStatus) {
WebSocketService.WebSocketStatus.CONNECTED -> "WebSocket 已连接"
WebSocketService.WebSocketStatus.CONNECTING -> "WebSocket 连接中"
WebSocketService.WebSocketStatus.DISCONNECTED -> "WebSocket 未连接"
WebSocketService.WebSocketStatus.ERROR -> "WebSocket 连接错误"
},
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "收到 ${receivedMessages.size} 条消息",
fontSize = 14.sp,
color = androidx.compose.ui.graphics.Color.Gray
)
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = { viewModel.reconnectWebSocket(authToken) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = when (webSocketStatus) {
WebSocketService.WebSocketStatus.CONNECTED -> androidx.compose.ui.graphics.Color.Green
WebSocketService.WebSocketStatus.CONNECTING -> androidx.compose.ui.graphics.Color.Yellow
else -> androidx.compose.ui.graphics.Color.Red
}
)
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = "重连",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (webSocketStatus) {
WebSocketService.WebSocketStatus.CONNECTED -> "连接正常"
WebSocketService.WebSocketStatus.CONNECTING -> "连接中..."
else -> "重连 WebSocket"
},
fontSize = 14.sp
)
}
}
}
}
// 自动扫描状态显示(调试用)
if (autoScanStatus.isNotEmpty()) {
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = androidx.compose.ui.graphics.Color(0xFFE3F2FD)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "自动文件扫描状态",
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = androidx.compose.ui.graphics.Color.Blue
)
Spacer(modifier = Modifier.height(8.dp))
Text(autoScanStatus)
}
}
}
}
// 上传状态显示
item {
when (uploadStatus) {
CallRecordManager.UploadStatus.UPLOADING -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = androidx.compose.ui.graphics.Color(0xFFE8F5E8)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "正在自动上传通话记录...",
color = androidx.compose.ui.graphics.Color.Blue,
fontWeight = FontWeight.Medium
)
}
}
}
CallRecordManager.UploadStatus.SUCCESS -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = androidx.compose.ui.graphics.Color(0xFFE8F5E8)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "成功",
tint = androidx.compose.ui.graphics.Color.Green
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "上传成功",
color = androidx.compose.ui.graphics.Color.Green,
fontWeight = FontWeight.Medium
)
}
}
}
CallRecordManager.UploadStatus.FAILED -> {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = androidx.compose.ui.graphics.Color(0xFFFFEBEE)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "失败",
tint = androidx.compose.ui.graphics.Color.Red
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "上传失败",
color = androidx.compose.ui.graphics.Color.Red,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "系统将自动重试,或您可以手动重新上传",
fontSize = 14.sp,
color = androidx.compose.ui.graphics.Color.Gray
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.manuallyUploadRecentCall() },
modifier = Modifier.fillMaxWidth()
) {
Text("重新上传")
}
}
}
}
else -> {}
}
}
// 当前通话状态
currentCall?.let { call ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "当前通话",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = androidx.compose.ui.graphics.Color.Blue
)
Spacer(modifier = Modifier.height(8.dp))
Text("📞 号码: ${call.phoneNumber}")
Text("🕐 开始时间: ${call.formattedStartTime}")
Text("📍 状态: ${call.status}")
Spacer(modifier = Modifier.height(12.dp))
Button(
onClick = {
CallRecordManager.endCallRecord()
println("📞 手动结束通话")
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = androidx.compose.ui.graphics.Color.Red
)
) {
Text("结束通话")
}
}
}
}
}
// 最近通话记录
recentCall?.let { call ->
item {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
colors = CardDefaults.cardColors(
containerColor = if (call.uploaded) androidx.compose.ui.graphics.Color(0xFFE8F5E8)
else androidx.compose.ui.graphics.Color(0xFFFFF8E1)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Text(
text = "最近通话记录",
fontWeight = FontWeight.Bold,
fontSize = 18.sp,
color = if (call.uploaded) androidx.compose.ui.graphics.Color.Green
else androidx.compose.ui.graphics.Color(0xFFFF9800)
)
Spacer(modifier = Modifier.height(8.dp))
Text("📞 号码: ${call.phoneNumber}")
Text("🕐 开始时间: ${call.formattedStartTime}")
Text("🕐 结束时间: ${call.formattedEndTime}")
Text("⏱️ 时长: ${call.formattedDuration}")
Text("🎵 音频文件: ${call.audioFileName ?: "自动查找中..."}")
Text("📍 状态: ${call.status}")
Text(
text = if (call.uploaded) "✅ 已上传" else "${if (call.audioFileName != null) "准备上传" else "查找文件中"}",
color = if (call.uploaded) androidx.compose.ui.graphics.Color.Green
else androidx.compose.ui.graphics.Color(0xFFFF9800)
)
if (!call.uploaded && call.audioFileName != null) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.manuallyUploadRecentCall() },
modifier = Modifier.fillMaxWidth()
) {
Text("手动上传")
}
}
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = { viewModel.clearRecentCall() },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = androidx.compose.ui.graphics.Color.Gray
)
) {
Text("清除记录")
}
}
}
}
}
// 添加底部空间
item {
Spacer(modifier = Modifier.height(32.dp))
}
}
}
}
}
@Composable
fun CallRecordItem(record: CallRecord) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(
containerColor = if (record.uploaded) androidx.compose.ui.graphics.Color(0xFFE8F5E8)
else androidx.compose.ui.graphics.Color(0xFFFFF3E0)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = record.phoneNumber,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = androidx.compose.ui.graphics.Color.Black
)
// 上传状态指示器
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(8.dp)
.background(
color = if (record.uploaded) androidx.compose.ui.graphics.Color.Green
else androidx.compose.ui.graphics.Color.Red,
shape = androidx.compose.foundation.shape.CircleShape
)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = if (record.uploaded) "已上传" else "未上传",
fontSize = 12.sp,
color = if (record.uploaded) androidx.compose.ui.graphics.Color.Green
else androidx.compose.ui.graphics.Color.Red
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Text("📞 号码: ${record.phoneNumber}", fontSize = 14.sp)
Text("🕐 开始: ${record.formattedStartTime}", fontSize = 14.sp)
Text("🕐 结束: ${record.formattedEndTime}", fontSize = 14.sp)
Text("⏱️ 时长: ${record.formattedDuration}", fontSize = 14.sp)
Text("🎵 音频: ${record.audioFileName ?: "无"}", fontSize = 14.sp)
Text("📍 状态: ${record.status}", fontSize = 14.sp)
if (record.uploaded) {
Text(
text = "✅ 上传成功",
fontSize = 12.sp,
color = androidx.compose.ui.graphics.Color.Green,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}

View File

@@ -0,0 +1,291 @@
package com.example.initiateaphonecallapp.ui.home
import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.CallRecord
import com.example.initiateaphonecallapp.data.model.SelectedAudioFile
import com.example.initiateaphonecallapp.enums.CallStatus
import com.example.initiateaphonecallapp.enums.MessageType
import com.example.initiateaphonecallapp.manager.CallRecordManager
import com.example.initiateaphonecallapp.service.PhoneCallService
import com.example.initiateaphonecallapp.service.WebSocketService
import com.example.initiateaphonecallapp.utils.CallRecordScanner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
class HomeViewModel(application: Application) : AndroidViewModel(application) {
// // 使用 CallRecordManager 的状态
// val callRecords: StateFlow<List<com.example.initiateaphonecallapp.data.model.CallRecord>> = CallRecordManager.callRecords
// val currentCall: StateFlow<com.example.initiateaphonecallapp.data.model.CallRecord?> = CallRecordManager.currentCall
// val recentCall: StateFlow<com.example.initiateaphonecallapp.data.model.CallRecord?> = CallRecordManager.recentCall
// val uploadStatus: StateFlow<CallRecordManager.UploadStatus> = CallRecordManager.uploadStatus
//
//
//
// private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED)
// val webSocketStatus: StateFlow<WebSocketService.WebSocketStatus> = _webSocketStatus.asStateFlow()
//
// private val _receivedMessages = MutableStateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>>(emptyList())
// val receivedMessages: StateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>> = _receivedMessages.asStateFlow()
// 使用 CallRecordManager 的状态流
private val _callRecords = MutableStateFlow(CallRecordManager.callRecords.value)
val callRecords: StateFlow<List<CallRecord>> = _callRecords.asStateFlow()
private val _currentCall = MutableStateFlow(CallRecordManager.currentCall.value)
val currentCall: StateFlow<CallRecord?> = _currentCall.asStateFlow()
private val _recentCall = MutableStateFlow(CallRecordManager.recentCall.value)
val recentCall: StateFlow<CallRecord?> = _recentCall.asStateFlow()
private val _uploadStatus = MutableStateFlow(CallRecordManager.uploadStatus.value)
val uploadStatus: StateFlow<CallRecordManager.UploadStatus> = _uploadStatus.asStateFlow()
private val _selectedAudioFile = MutableStateFlow(CallRecordManager.selectedAudioFile.value)
val selectedAudioFile: StateFlow<SelectedAudioFile?> = _selectedAudioFile.asStateFlow()
private val _webSocketStatus = MutableStateFlow(WebSocketService.WebSocketStatus.DISCONNECTED)
val webSocketStatus: StateFlow<WebSocketService.WebSocketStatus> = _webSocketStatus.asStateFlow()
private val _receivedMessages = MutableStateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>>(emptyList())
val receivedMessages: StateFlow<List<com.example.initiateaphonecallapp.data.model.WebSocketMessage>> = _receivedMessages.asStateFlow()
// // 选择的音频文件通过 CallRecordManager 管理
// val selectedAudioFile: StateFlow<SelectedAudioFile?>
// get() = CallRecordManager.selectedAudioFile
// 通话状态监听
private var callStartTime: Date? = null
fun setSelectedAudioFile(uri: Uri, fileName: String) {
if (uri == Uri.EMPTY) {
CallRecordManager.setSelectedAudioFile(null)
} else {
CallRecordManager.setSelectedAudioFile(SelectedAudioFile(uri, fileName))
}
}
// 获取当前选择的音频文件信息
fun getSelectedAudioFile(): SelectedAudioFile? {
return CallRecordManager.getSelectedAudioFile()
}
fun reconnectWebSocket(token: String) {
viewModelScope.launch {
val intent = Intent(getApplication(), WebSocketService::class.java).apply {
action = "RECONNECT"
putExtra("TOKEN", token)
}
getApplication<Application>().startService(intent)
println("🔄 HomeViewModel: 请求重连 WebSocket")
}
}
// 清除选择的文件
fun clearSelectedAudioFile() {
CallRecordManager.setSelectedAudioFile(null)
}
// 手动上传最近通话记录
fun manuallyUploadRecentCall() {
CallRecordManager.manuallyUploadRecentCall()
}
// 清除最近通话记录
fun clearRecentCall() {
CallRecordManager.clearRecentCall()
}
// 添加自动扫描状态(用于调试)
private val _autoScanStatus = MutableStateFlow("")
val autoScanStatus: StateFlow<String> = _autoScanStatus.asStateFlow()
private fun observeCallRecordManager() {
viewModelScope.launch {
CallRecordManager.callRecords.collect { records ->
_callRecords.value = records
println("🏠 HomeViewModel: 通话记录更新,数量: ${records.size}")
}
}
viewModelScope.launch {
CallRecordManager.currentCall.collect { call ->
_currentCall.value = call
println("🏠 HomeViewModel: 当前通话更新: ${call?.phoneNumber ?: "无"}")
}
}
viewModelScope.launch {
CallRecordManager.recentCall.collect { recent ->
_recentCall.value = recent
println("🏠 HomeViewModel: 最近通话更新: ${recent?.phoneNumber ?: "无"}")
}
}
viewModelScope.launch {
CallRecordManager.uploadStatus.collect { status ->
_uploadStatus.value = status
println("🏠 HomeViewModel: 上传状态更新: $status")
}
}
viewModelScope.launch {
CallRecordManager.selectedAudioFile.collect { audioFile ->
_selectedAudioFile.value = audioFile
println("🏠 HomeViewModel: 音频文件更新: ${audioFile?.fileName ?: "无"}")
}
}
}
private fun observeWebSocketStatus() {
viewModelScope.launch {
WebSocketService.webSocketStatus.observeForever { status ->
viewModelScope.launch {
_webSocketStatus.value = status
println("🏠 HomeViewModel: WebSocket 状态更新为: $status")
}
}
}
}
private fun observeWebSocketMessages() {
WebSocketService.receivedMessages.observeForever { message ->
message?.let {
viewModelScope.launch {
_receivedMessages.value = _receivedMessages.value + it
println("🏠 HomeViewModel: 收到新消息: $it")
// 如果是电话呼叫消息,开始通话记录
if (it.action == MessageType.CALL && it.phone != null) {
CallRecordManager.startCallRecord(it.phone!!)
// WebSocketService 会自动处理电话呼叫
}
}
}
}
}
init {
// 观察 CallRecordManager 的所有状态变化
observeCallRecordManager()
// 观察 WebSocket 状态
observeWebSocketStatus()
// 监听 WebSocket 消息
observeWebSocketMessages()
// 观察 WebSocket 状态
viewModelScope.launch {
// 监听 WebSocket 状态变化
WebSocketService.webSocketStatus.observeForever { status ->
viewModelScope.launch {
_webSocketStatus.value = status
println("🏠 HomeViewModel: WebSocket 状态更新为: $status")
}
}
}
// 监听 WebSocket 消息
WebSocketService.receivedMessages.observeForever { message ->
message?.let {
viewModelScope.launch {
_receivedMessages.value = _receivedMessages.value + it
println("🏠 HomeViewModel: 收到新消息: $it")
/// 如果是电话呼叫消息,开始通话记录
if (it.action == MessageType.CALL && it.phone != null) {
CallRecordManager.startCallRecord(it.phone!!)
// WebSocketService 会自动处理电话呼叫
}
}
}
}
}
//==========================================================================================================================================================================================
// 在 HomeViewModel 中添加以下代码
private val _callRecordFiles = MutableStateFlow<List<CallRecordScanner.CallRecordFile>>(emptyList())
val callRecordFiles: StateFlow<List<CallRecordScanner.CallRecordFile>> = _callRecordFiles.asStateFlow()
private val _scanStatus = MutableStateFlow("")
val scanStatus: StateFlow<String> = _scanStatus.asStateFlow()
// 扫描通话录音文件
// 扫描通话录音文件
fun scanCallRecords() {
viewModelScope.launch {
try {
_scanStatus.value = "正在扫描通话录音..."
println("🔍 开始扫描所有通话录音文件")
// 使用新的扫描方法(不限制文件名格式)
val records = CallRecordScanner.scanAllCallRecords(getApplication())
_callRecordFiles.value = records
val status = when {
records.isNotEmpty() -> "找到 ${records.size} 个录音文件(宽松匹配)"
CallRecordScanner.hasAccessibleRecordDirs() -> "找到录音目录,但没有录音文件"
else -> "未找到通话录音目录"
}
_scanStatus.value = status
println("📊 扫描完成: $status")
// 打印查询路径信息
println("📁 查询路径:")
CallRecordScanner.getQueryPaths().forEach { (brand, path) ->
println(" $brand: $path")
}
// 打印找到的文件信息
records.forEach { record ->
println("📄 文件: ${record.displayName}")
println(" 📞 解析手机号: ${record.phoneNumber ?: "未解析"}")
println(" 🏷️ 品牌: ${record.brand}")
println(" 📁 目录: ${record.directory}")
}
} catch (e: Exception) {
_scanStatus.value = "扫描失败: ${e.message}"
println("❌ 扫描失败: ${e.message}")
}
}
}
// 选择录音文件
fun selectCallRecordFile(recordFile: CallRecordScanner.CallRecordFile) {
viewModelScope.launch {
try {
setSelectedAudioFile(recordFile.uri, recordFile.displayName)
_scanStatus.value = "已选择: ${recordFile.displayName}"
println("✅ 选择录音文件: ${recordFile.displayName}")
} catch (e: Exception) {
_scanStatus.value = "选择失败: ${e.message}"
println("❌ 选择录音文件失败: ${e.message}")
}
}
}
}

View File

@@ -0,0 +1,248 @@
// ui/login/LoginScreen.kt
package com.example.initiateaphonecallapp.ui.login
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(
viewModel: LoginViewModel = viewModel(),
onLoginSuccess: (String) -> Unit = {}
) {
val loginState by viewModel.loginState.collectAsState()
val autoLoginState by viewModel.autoLoginState.collectAsState()
val rememberMe by viewModel.rememberMe.collectAsState()
val autoLogin by viewModel.autoLogin.collectAsState()
// 监听登录成功状态
LaunchedEffect(loginState.isLoginSuccess) {
if (loginState.isLoginSuccess && loginState.authToken != null) {
onLoginSuccess(loginState.authToken!!)
}
}
// 显示自动登录状态
if (autoLoginState == LoginViewModel.AutoLoginState.CHECKING) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator(
modifier = Modifier.size(50.dp),
color = Color(0xFF10A37F)
)
Spacer(modifier = Modifier.height(20.dp))
Text(
text = "自动登录中...",
fontSize = 16.sp,
color = Color.Gray
)
}
}
return
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.White)
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
// Logo/标题区域
Icon(
painter = painterResource(id = android.R.drawable.ic_dialog_email),
contentDescription = "App Logo",
modifier = Modifier.size(80.dp),
tint = Color(0xFF10A37F)
)
Spacer(modifier = Modifier.height(40.dp))
// 标题
Text(
text = "登录您的账户",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "欢迎回来,请登录您的账户",
fontSize = 14.sp,
color = Color.Gray,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(40.dp))
// 用户名输入框
OutlinedTextField(
value = loginState.username,
onValueChange = viewModel::onUsernameChange,
label = { Text("账号") },
placeholder = { Text("请输入您的账号") },
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(0xFF10A37F),
unfocusedBorderColor = Color(0xFFE5E5E5),
focusedTextColor = Color.Black,
focusedPlaceholderColor = Color(0xFF999999),
unfocusedTextColor = Color.Black,
unfocusedPlaceholderColor = Color(0xFF999999)
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text)
)
Spacer(modifier = Modifier.height(20.dp))
// 密码输入框
OutlinedTextField(
value = loginState.password,
onValueChange = viewModel::onPasswordChange,
label = { Text("密码") },
placeholder = { Text("请输入您的密码") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp)),
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = Color(0xFF10A37F),
unfocusedBorderColor = Color(0xFFE5E5E5),
focusedTextColor = Color.Black,
focusedPlaceholderColor = Color(0xFF999999),
unfocusedTextColor = Color.Black,
unfocusedPlaceholderColor = Color(0xFF999999)
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)
)
// 记住我和自动登录选项
Spacer(modifier = Modifier.height(16.dp))
Column(
modifier = Modifier.fillMaxWidth()
) {
// 记住我选项
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = rememberMe,
onCheckedChange = viewModel::onRememberMeChange,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF10A37F)
)
)
Text(
text = "记住密码",
fontSize = 14.sp,
color = Color.Black,
modifier = Modifier.padding(start = 8.dp)
)
}
// 自动登录选项
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Checkbox(
checked = autoLogin,
onCheckedChange = viewModel::onAutoLoginChange,
enabled = rememberMe,
colors = CheckboxDefaults.colors(
checkedColor = Color(0xFF10A37F)
)
)
Text(
text = "自动登录",
fontSize = 14.sp,
color = if (rememberMe) Color.Black else Color.Gray,
modifier = Modifier.padding(start = 8.dp)
)
}
}
// 错误信息显示
if (loginState.errorMessage.isNotEmpty()) {
Spacer(modifier = Modifier.height(16.dp))
Text(
text = loginState.errorMessage,
color = Color.Red,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
}
Spacer(modifier = Modifier.height(40.dp))
// 登录按钮
Button(
onClick = {
viewModel.login()
},
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
.clip(RoundedCornerShape(12.dp)),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF10A37F),
disabledContainerColor = Color(0xFFCCCCCC)
),
enabled = !loginState.isLoading
) {
if (loginState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = "登录",
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(20.dp))
}
}
}

View File

@@ -0,0 +1,10 @@
package com.example.initiateaphonecallapp.ui.login
data class LoginState(
val username: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String = "",
val isLoginSuccess: Boolean = false,
val authToken: String? = null
)

View File

@@ -0,0 +1,191 @@
package com.example.initiateaphonecallapp.ui.login
import android.os.Build
import androidx.annotation.RequiresExtension
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.LoginResponse
import com.example.initiateaphonecallapp.data.repository.AuthRepository
import com.example.initiateaphonecallapp.manager.PreferenceManager
import com.example.initiateaphonecallapp.manager.TokenManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class LoginViewModel( private val authRepository: AuthRepository = AuthRepository()) : ViewModel() {
private val _loginState = MutableStateFlow(LoginState())
val loginState: StateFlow<LoginState> = _loginState.asStateFlow()
// 自动登录状态
private val _autoLoginState = MutableStateFlow(AutoLoginState.IDLE)
val autoLoginState: StateFlow<AutoLoginState> = _autoLoginState
// 记住我和自动登录状态
var rememberMe = MutableStateFlow(false)
var autoLogin = MutableStateFlow(false)
enum class AutoLoginState {
IDLE, CHECKING, SUCCESS, FAILED
}
init {
// 应用启动时检查自动登录
checkAutoLogin()
}
fun onUsernameChange(username: String) {
_loginState.value = _loginState.value.copy(
username = username,
errorMessage = ""
)
}
fun onPasswordChange(password: String) {
_loginState.value = _loginState.value.copy(
password = password,
errorMessage = ""
)
}
fun onRememberMeChange(remember: Boolean) {
rememberMe.value = remember
// 如果取消记住我,也取消自动登录
if (!remember) {
autoLogin.value = false
}
}
fun onAutoLoginChange(auto: Boolean) {
autoLogin.value = auto
// 如果启用自动登录,必须启用记住我
if (auto) {
rememberMe.value = true
}
}
// 检查自动登录
private fun checkAutoLogin() {
viewModelScope.launch {
// 检查是否启用了自动登录且有保存的 token
if (PreferenceManager.isAutoLogin() && PreferenceManager.hasValidToken()) {
_autoLoginState.value = AutoLoginState.CHECKING
println("🔐 LoginViewModel: 开始自动登录")
// 从存储中获取用户名
val savedUsername = PreferenceManager.getSavedUsername()
// 填充用户名到登录状态
_loginState.value = _loginState.value.copy(username = savedUsername)
// 恢复 token
PreferenceManager.restoreToken()
// 模拟自动登录过程
kotlinx.coroutines.delay(1000)
// 自动登录成功
_loginState.value = _loginState.value.copy(
isLoginSuccess = true,
authToken = TokenManager.getToken()
)
_autoLoginState.value = AutoLoginState.SUCCESS
println("✅ LoginViewModel: 自动登录成功")
} else {
// 如果只是记住我,填充用户名
if (PreferenceManager.isRememberMe()) {
val savedUsername = PreferenceManager.getSavedUsername()
_loginState.value = _loginState.value.copy(username = savedUsername)
}
_autoLoginState.value = AutoLoginState.IDLE
}
}
}
fun login() {
val currentState = _loginState.value
if (currentState.username.isEmpty() || currentState.password.isEmpty()) {
_loginState.value = currentState.copy(
errorMessage = "请填写账号和密码"
)
return
}
_loginState.value = currentState.copy(
isLoading = true,
errorMessage = ""
)
viewModelScope.launch {
// 模拟网络请求
val result = authRepository.login(
username = currentState.username,
password = currentState.password
)
result.fold(
onSuccess = { loginResponse ->
if (loginResponse.code==200) {
// 登录成功
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = true,
authToken = loginResponse.token,
errorMessage = ""
)
// 保存登录信息
loginResponse.token?.let {
PreferenceManager.saveLoginInfo(
username = currentState.username,
password = currentState.password,
token = it,
rememberMe = rememberMe.value,
autoLogin = autoLogin.value
)
}
TokenManager.setToken(loginResponse.token!!)
} else {
// 登录失败(服务器返回成功但业务逻辑失败)
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = false,
authToken = null,
errorMessage = loginResponse.msg ?: "登录失败"
)
}
},
onFailure = { exception ->
// 网络请求失败
_loginState.value = currentState.copy(
isLoading = false,
isLoginSuccess = false,
authToken = null,
errorMessage = exception.message ?: "网络请求失败"
)
}
)
}
}
// 清除保存的登录信息
fun clearSavedLoginInfo() {
PreferenceManager.clearLoginInfo()
rememberMe.value = false
autoLogin.value = false
_loginState.value = LoginState()
println("🔐 LoginViewModel: 已清除保存的登录信息")
}
// 获取认证token在其他地方使用
fun getAuthToken(): String? {
return _loginState.value.authToken
}
}

View File

@@ -0,0 +1,487 @@
// ui/pipeline/PipelineScreen.kt
package com.example.initiateaphonecallapp.ui.pipeline
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Phone
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.example.initiateaphonecallapp.data.model.PipelineResponse
import com.example.initiateaphonecallapp.service.PhoneCallService
@Composable
fun PipelineScreen(modifier: Modifier = Modifier) {
val viewModel: PipelineViewModel = viewModel()
val pipelines by viewModel.pipelines.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val errorMessage by viewModel.errorMessage.collectAsState()
val isAutoDialing by viewModel.isAutoDialing.collectAsState()
val currentDialingIndex by viewModel.currentDialingIndex.collectAsState()
val context = LocalContext.current
// 获取可拨打的管道列表
val dialablePipelines = pipelines.filter { it.status == 0 || it.status == 1 }
// 处理生命周期
LaunchedEffect(key1 = context) {
viewModel.registerAutoDialReceiver(context)
}
DisposableEffect(key1 = context) {
onDispose {
viewModel.unregisterAutoDialReceiver(context)
}
}
// 处理错误消息
if (errorMessage != null) {
AlertDialog(
onDismissRequest = { viewModel.clearError() },
title = { Text("错误") },
text = { Text(errorMessage!!) },
confirmButton = {
TextButton(onClick = { viewModel.clearError() }) {
Text("确定")
}
}
)
}
Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
) {
// 标题和刷新按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "客户管道",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
IconButton(
onClick = { viewModel.loadPipelines() },
enabled = !isLoading
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "刷新"
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 自动拨号控制面板 - 使用可拨打的管道数量
AutoDialControlPanel(
isAutoDialing = isAutoDialing,
currentIndex = currentDialingIndex,
totalCount = dialablePipelines.size, // 使用可拨打的管道数量
onStartAutoDial = {
if (dialablePipelines.isNotEmpty()) {
viewModel.startAutoDial()
startAutoDial(context, ArrayList(dialablePipelines))
}
},
onStopAutoDial = {
viewModel.stopAutoDial()
stopAutoDial(context)
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
if (isLoading) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else if (pipelines.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text("暂无客户数据")
}
} else {
// 显示统计信息
Text(
text = "总计: ${pipelines.size} 个客户,可拨打: ${dialablePipelines.size}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// 管道列表
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(pipelines) { pipeline ->
PipelineItem(
pipeline = pipeline,
isCurrentDialing = isAutoDialing &&
dialablePipelines.getOrNull(currentDialingIndex)?.id == pipeline.id,
onCallClick = {
// 修改:手动拨号后继续自动拨号
initiatePhoneCallAndContinueAutoDial(
context = context,
viewModel = viewModel,
pipeline = pipeline,
dialablePipelines = dialablePipelines
)
},
onStatusChange = { newStatus ->
viewModel.updatePipelineStatus(pipeline.id, newStatus)
}
)
}
}
}
}
}
@Composable
fun AutoDialControlPanel(
isAutoDialing: Boolean,
currentIndex: Int,
totalCount: Int,
onStartAutoDial: () -> Unit,
onStopAutoDial: () -> Unit,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = if (isAutoDialing) Color(0xFFFFF8E1) else Color(0xFFF5F5F5)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = if (isAutoDialing) "自动拨号进行中" else "自动拨号",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (isAutoDialing) {
Text(
text = "进度: ${currentIndex + 1}/$totalCount",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
Text(
text = "每10秒自动拨打下一个",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
if (isAutoDialing) {
FilledTonalButton(
onClick = onStopAutoDial,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color(0xFFF44336)
)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "停止自动拨号",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("停止")
}
} else {
FilledTonalButton(
onClick = onStartAutoDial,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color(0xFF4CAF50)
)
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = "开始自动拨号",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("开始自动拨号")
}
}
}
if (isAutoDialing) {
Spacer(modifier = Modifier.height(8.dp))
LinearProgressIndicator(
progress = if (totalCount > 0) (currentIndex + 1).toFloat() / totalCount else 0f,
modifier = Modifier.fillMaxWidth(),
color = Color(0xFF4CAF50)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PipelineItem(
pipeline: PipelineResponse,
isCurrentDialing: Boolean = false,
onCallClick: () -> Unit,
onStatusChange: (Int) -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = if (isCurrentDialing) {
CardDefaults.cardColors(containerColor = Color(0xFFE8F5E8))
} else {
CardDefaults.cardColors()
}
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// 客户信息和电话按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = pipeline.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = pipeline.phone,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// 显示状态
StatusBadge(status = pipeline.status)
}
// 拨打电话按钮
FilledTonalButton(
onClick = onCallClick,
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = Color(0xFF4CAF50)
)
) {
Icon(
imageVector = Icons.Default.Phone,
contentDescription = "拨打电话",
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("从此处拨打")
}
}
Spacer(modifier = Modifier.height(12.dp))
// // 状态选择器
// StatusSelector(
// currentStatus = pipeline.status,
// onStatusChange = onStatusChange
// )
}
}
}
// 状态徽章组件
@Composable
fun StatusBadge(status: Int) {
val (text, color) = when (status) {
0 -> Pair("未联系", Color(0xFFFF5722)) // 橙色
1 -> Pair("未接通", Color(0xFF2196F3)) // 蓝色
else -> Pair("已接通", Color(0xFF757575))
}
Surface(
shape = MaterialTheme.shapes.small,
color = color.copy(alpha = 0.1f),
border = BorderStroke(1.dp, color.copy(alpha = 0.3f))
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
// 状态选择器组件
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StatusSelector(
currentStatus: Int,
onStatusChange: (Int) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
val statusOptions = listOf(
"未联系" to 0,
"未接通" to 1,
"已接通" to 2,
"已预约" to 3,
"已成交" to 4
)
val currentStatusText = statusOptions.find { it.second == currentStatus }?.first ?: "未知状态"
Box(
modifier = Modifier.fillMaxWidth()
) {
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = currentStatusText,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
label = { Text("更新状态") },
colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
statusOptions.forEach { (text, value) ->
DropdownMenuItem(
text = { Text(text) },
onClick = {
onStatusChange(value)
expanded = false
}
)
}
}
}
}
}
// 拨打电话的函数
private fun initiatePhoneCall(context: Context, phoneNumber: String, pipelineId: String) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_INITIATE_CALL"
putExtra("PHONE_NUMBER", phoneNumber)
putExtra("PIPELINE_ID", pipelineId)
}
context.startService(intent)
}
// 新增:手动拨号后继续自动拨号的函数
private fun initiatePhoneCallAndContinueAutoDial(
context: Context,
viewModel: PipelineViewModel,
pipeline: PipelineResponse,
dialablePipelines: List<PipelineResponse>
) {
// 先拨打当前电话
initiatePhoneCall(context, pipeline.phone, pipeline.id)
// 找到当前管道在可拨打列表中的位置
val currentIndex = dialablePipelines.indexOfFirst { it.id == pipeline.id }
if (currentIndex != -1) {
// 如果当前不是自动拨号状态,则启动自动拨号
if (!viewModel.isAutoDialing.value) {
viewModel.startAutoDial()
viewModel.updateDialingIndex(currentIndex)
startAutoDialFromCurrent(context, ArrayList(dialablePipelines), currentIndex)
} else {
// 如果已经是自动拨号状态,则更新当前索引
viewModel.updateDialingIndex(currentIndex)
// 通知服务更新当前索引
updateAutoDialIndex(context, ArrayList(dialablePipelines), currentIndex)
}
}
}
// 新增:从指定位置开始自动拨号
private fun startAutoDialFromCurrent(
context: Context,
pipelineList: ArrayList<PipelineResponse>,
startIndex: Int
) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_START_AUTO_DIAL_FROM_CURRENT"
putExtra("PIPELINE_LIST", pipelineList)
putExtra("START_INDEX", startIndex)
}
context.startService(intent)
}
// 新增:更新自动拨号索引
private fun updateAutoDialIndex(
context: Context,
pipelineList: ArrayList<PipelineResponse>,
currentIndex: Int
) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_UPDATE_DIAL_INDEX"
putExtra("PIPELINE_LIST", pipelineList)
putExtra("CURRENT_INDEX", currentIndex)
}
context.startService(intent)
}
// 开始自动拨号
private fun startAutoDial(context: Context, pipelineList: ArrayList<PipelineResponse>) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_START_AUTO_DIAL"
putExtra("PIPELINE_LIST", pipelineList)
}
context.startService(intent)
}
// 停止自动拨号
private fun stopAutoDial(context: Context) {
val intent = Intent(context, PhoneCallService::class.java).apply {
action = "ACTION_STOP_AUTO_DIAL"
}
context.startService(intent)
}

View File

@@ -0,0 +1,120 @@
// ui/pipeline/PipelineViewModel.kt
package com.example.initiateaphonecallapp.ui.pipeline
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.PipelineResponse
import com.example.initiateaphonecallapp.data.repository.PiplineRepository
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class PipelineViewModel : ViewModel() {
private val repository = PiplineRepository()
private val _pipelines = MutableStateFlow<List<PipelineResponse>>(emptyList())
val pipelines: StateFlow<List<PipelineResponse>> = _pipelines.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _errorMessage = MutableStateFlow<String?>(null)
val errorMessage: StateFlow<String?> = _errorMessage.asStateFlow()
// 自动拨号状态
private val _isAutoDialing = MutableStateFlow(false)
val isAutoDialing: StateFlow<Boolean> = _isAutoDialing.asStateFlow()
private val _currentDialingIndex = MutableStateFlow(0)
val currentDialingIndex: StateFlow<Int> = _currentDialingIndex.asStateFlow()
// 获取可拨打的管道列表status为0或1
val dialablePipelines: List<PipelineResponse>
get() = _pipelines.value.filter { it.status == 0 || it.status == 1 }
init {
loadPipelines()
}
fun loadPipelines() {
viewModelScope.launch {
_isLoading.value = true
_errorMessage.value = null
val result = repository.getPipelines()
result.onSuccess { pipelineList ->
_pipelines.value = pipelineList
}.onFailure { exception ->
_errorMessage.value = "加载失败: ${exception.message}"
}
_isLoading.value = false
}
}
fun clearError() {
_errorMessage.value = null
}
fun updatePipelineStatus(pipelineId: String, newStatus: Int) {
val updatedList = _pipelines.value.map { pipeline ->
if (pipeline.id == pipelineId) {
pipeline.copy(status = newStatus)
} else {
pipeline
}
}
_pipelines.value = updatedList
}
// 自动拨号控制
fun startAutoDial() {
_isAutoDialing.value = true
_currentDialingIndex.value = 0
}
fun stopAutoDial() {
_isAutoDialing.value = false
_currentDialingIndex.value = 0
}
fun updateDialingIndex(index: Int) {
_currentDialingIndex.value = index
}
// 注册广播接收器来监听自动拨号状态
fun registerAutoDialReceiver(context: Context) {
val filter = IntentFilter().apply {
addAction("AUTO_DIAL_STOPPED")
addAction("AUTO_DIAL_NEXT")
}
context.registerReceiver(autoDialReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
}
fun unregisterAutoDialReceiver(context: Context) {
try {
context.unregisterReceiver(autoDialReceiver)
} catch (e: IllegalArgumentException) {
// 接收器未注册,忽略错误
}
}
private val autoDialReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
"AUTO_DIAL_STOPPED" -> {
stopAutoDial()
}
"AUTO_DIAL_NEXT" -> {
val index = intent.getIntExtra("current_index", 0)
updateDialingIndex(index)
}
}
}
}
}

View File

@@ -0,0 +1,320 @@
// ProfileScreen.kt
package com.example.initiateaphonecallapp.ui.profile
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.android.volley.toolbox.ImageRequest
import com.example.initiateaphonecallapp.data.model.Profile
@Composable
fun ProfileScreen(
modifier: Modifier = Modifier,
viewModel: ProfileViewModel = viewModel()
) {
val profileState by viewModel.profileState.collectAsState()
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
when (profileState) {
is ProfileState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is ProfileState.Success -> {
val profile = (profileState as ProfileState.Success).profile
ProfileContent(profile = profile, onRefresh = { viewModel.refreshProfile() })
}
is ProfileState.Error -> {
val errorMessage = (profileState as ProfileState.Error).message
ErrorView(
errorMessage = errorMessage,
onRetry = { viewModel.refreshProfile() }
)
}
}
}
}
@Composable
private fun ProfileContent(
profile: Profile,
onRefresh: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// 头像和基本信息区域
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(
modifier = Modifier.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// 头像
if (!profile.avatar.isNullOrEmpty()) {
} else {
// 默认头像
Icon(
imageVector = Icons.Default.Person,
contentDescription = "默认头像",
modifier = Modifier
.size(80.dp)
.padding(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
Spacer(modifier = Modifier.height(16.dp))
// 姓名和用户名
Text(
text = profile.nickName,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "@${profile.userName}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
// 状态标签
Surface(
color = when (profile.status) {
"正常" -> MaterialTheme.colorScheme.primaryContainer
else -> MaterialTheme.colorScheme.errorContainer
},
shape = MaterialTheme.shapes.small
) {
Text(
text = profile.status,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = when (profile.status) {
"正常" -> MaterialTheme.colorScheme.onPrimaryContainer
else -> MaterialTheme.colorScheme.onErrorContainer
}
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// 详细信息区域
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "基本信息",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
// 部门信息
InfoItem(
icon = Icons.Default.Info,
label = "部门",
value = profile.deptName
)
// 角色信息
InfoItem(
icon = Icons.Default.Info,
label = "角色",
value = profile.roleNames.joinToString(", ")
)
// 性别
InfoItem(
icon = Icons.Default.Person,
label = "性别",
value = profile.sex
)
// 邮箱
InfoItem(
icon = Icons.Default.Email,
label = "邮箱",
value = profile.email
)
// 手机号
InfoItem(
icon = Icons.Default.Phone,
label = "手机号",
value = profile.phonenumber
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// 系统信息区域
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "系统信息",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
// 用户ID
InfoItem(
icon = Icons.Default.Info,
label = "用户ID",
value = profile.userId.toString()
)
// 创建时间
InfoItem(
icon = Icons.Default.DateRange,
label = "创建时间",
value = profile.createTime
)
// 最后登录IP
InfoItem(
icon = Icons.Default.Info,
label = "最后登录IP",
value = profile.loginIp
)
// 最后登录时间
InfoItem(
icon = Icons.Default.Info,
label = "最后登录时间",
value = profile.loginDate
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// 刷新按钮
Button(
onClick = onRefresh,
modifier = Modifier
.fillMaxWidth()
.height(50.dp)
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = "刷新",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "刷新信息")
}
}
}
@Composable
private fun InfoItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
value: String
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
}
}
@Composable
private fun ErrorView(
errorMessage: String,
onRetry: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "错误",
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "加载失败",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = errorMessage,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = androidx.compose.ui.text.style.TextAlign.Center
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Text(text = "重试")
}
}
}

View File

@@ -0,0 +1,79 @@
// ProfileViewModel.kt
package com.example.initiateaphonecallapp.ui.profile
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.initiateaphonecallapp.data.model.ApigetInfoResponse
import com.example.initiateaphonecallapp.data.model.Profile
import com.example.initiateaphonecallapp.data.network.RetrofitClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
sealed class ProfileState {
object Loading : ProfileState()
data class Success(val profile: Profile) : ProfileState()
data class Error(val message: String) : ProfileState()
}
class ProfileViewModel : ViewModel() {
private val _profileState = MutableStateFlow<ProfileState>(ProfileState.Loading)
val profileState: StateFlow<ProfileState> = _profileState.asStateFlow()
init {
loadProfile()
}
fun loadProfile() {
viewModelScope.launch {
_profileState.value = ProfileState.Loading
try {
val response = RetrofitClient.loginApiService.getInfo()
if (response.isSuccessful) {
val apiResponse = response.body()
if (apiResponse != null && apiResponse.code == 200) {
val user = apiResponse.user
val profile = Profile(
userId = user.userId,
userName = user.userName,
nickName = user.nickName,
email = user.email ?: "未设置",
phonenumber = user.phonenumber ?: "未设置",
sex = when (user.sex) {
"0" -> ""
"1" -> ""
else -> "未知"
},
avatar = user.avatar,
deptName = user.dept.deptName,
roleNames = user.roles.map { it.roleName },
createTime = user.createTime ?: "未知",
loginIp = user.loginIp ?: "未知",
loginDate = user.loginDate ?: "未知",
status = when (user.status) {
"0" -> "正常"
"1" -> "停用"
else -> "未知"
}
)
_profileState.value = ProfileState.Success(profile)
} else {
_profileState.value = ProfileState.Error(
apiResponse?.msg ?: "获取个人信息失败"
)
}
} else {
_profileState.value = ProfileState.Error("网络请求失败: ${response.code()}")
}
} catch (e: Exception) {
_profileState.value = ProfileState.Error("加载失败: ${e.message}")
}
}
}
fun refreshProfile() {
loadProfile()
}
}

View File

@@ -0,0 +1,11 @@
package com.example.initiateaphonecallapp.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)

View File

@@ -0,0 +1,57 @@
package com.example.initiateaphonecallapp.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun InitiateAPhoneCallAppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}

View File

@@ -0,0 +1,34 @@
package com.example.initiateaphonecallapp.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,13 @@
package com.example.initiateaphonecallapp.util
// utils/BroadcastConstants.kt
object BroadcastConstants {
const val ACTION_CALL_STARTED = "com.example.initiateaphonecallapp.ACTION_CALL_STARTED"
const val ACTION_CALL_ENDED = "com.example.initiateaphonecallapp.ACTION_CALL_ENDED"
const val ACTION_CALL_FAILED = "com.example.initiateaphonecallapp.ACTION_CALL_FAILED"
const val EXTRA_PHONE_NUMBER = "extra_phone_number"
const val EXTRA_CALL_DURATION = "extra_call_duration"
const val EXTRA_ERROR_MESSAGE = "extra_error_message"
}

View File

@@ -0,0 +1,386 @@
// utils/CallRecordScanner.kt
package com.example.initiateaphonecallapp.utils
import android.content.ContentUris
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import java.text.SimpleDateFormat
import java.util.*
object CallRecordScanner {
private const val TAG = "CallRecordScanner"
// 支持的录音目录路径和对应的品牌
private val recordDirs = mapOf(
"/MIUI/sound_recorder/call_rec/" to "小米",
"/Record/call/" to "华为",
"/Sounds/CallRecord/" to "华为其他机型",
"/AudioRecord/" to "其他机型"
)
data class CallRecordFile(
val id: Long,
val displayName: String,
val filePath: String,
val size: Long,
val dateModified: Long,
val uri: Uri,
val phoneNumber: String?,
val timestamp: String?,
val brand: String,
val directory: String
)
/**
* 扫描所有通话录音文件(不限制文件名格式)
*/
fun scanAllCallRecords(context: Context): List<CallRecordFile> {
val callRecords = mutableListOf<CallRecordFile>()
try {
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.SIZE,
MediaStore.Audio.Media.DATE_MODIFIED,
MediaStore.Audio.Media.DATA
)
// 只根据路径查询,不限制文件名格式
val selection = buildPathSelectionQuery()
val selectionArgs = buildPathSelectionArgs()
val sortOrder = "${MediaStore.Audio.Media.DATE_MODIFIED} DESC"
Log.d(TAG, "开始扫描所有通话录音文件...")
Log.d(TAG, "查询条件: $selection")
Log.d(TAG, "查询参数: ${selectionArgs.joinToString()}")
Log.d(TAG, "支持的目录:")
recordDirs.forEach { (path, brand) ->
Log.d(TAG, " $brand: $path")
}
context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_MODIFIED)
val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATA)
var foundCount = 0
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val dateModified = cursor.getLong(dateModifiedColumn)
val filePath = cursor.getString(dataColumn)
// 解析文件名获取手机号(宽松匹配)
val phoneNumber = extractPhoneNumberFromFileName(name)
// 获取品牌和目录信息
val (brand, directory) = getBrandAndDirectory(filePath)
val uri = ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
id
)
callRecords.add(
CallRecordFile(
id = id,
displayName = name,
filePath = filePath,
size = size,
dateModified = dateModified,
uri = uri,
phoneNumber = phoneNumber,
timestamp = extractTimestampFromFileName(name),
brand = brand,
directory = directory
)
)
foundCount++
Log.d(TAG, "找到通话录音 [$foundCount]:")
Log.d(TAG, " 文件名: $name")
Log.d(TAG, " 路径: $filePath")
Log.d(TAG, " 品牌: $brand")
Log.d(TAG, " 目录: $directory")
Log.d(TAG, " 解析手机号: $phoneNumber")
Log.d(TAG, " 大小: ${formatFileSize(size)}")
Log.d(TAG, " 修改时间: ${formatDate(dateModified * 1000)}")
}
Log.d(TAG, "扫描完成,找到 $foundCount 个通话录音文件")
}
} catch (e: Exception) {
Log.e(TAG, "扫描通话录音失败: ${e.message}", e)
}
return callRecords
}
/**
* 根据手机号前缀查找匹配的通话录音文件
*/
fun findCallRecordsByPhonePrefix(context: Context, phonePrefix: String): List<CallRecordFile> {
val allRecords = scanAllCallRecords(context)
return allRecords.filter { record ->
// 宽松匹配:只要文件名以手机号前缀开头就匹配
record.phoneNumber?.startsWith(phonePrefix) == true ||
// 或者文件名包含手机号前缀
record.displayName.contains(phonePrefix) ||
// 或者解析出的手机号包含前缀
(record.phoneNumber != null && record.phoneNumber.contains(phonePrefix))
}.sortedByDescending { it.dateModified }
}
/**
* 根据完整手机号查找最新的通话录音文件
*/
fun findLatestCallRecordByPhone(context: Context, phoneNumber: String): CallRecordFile? {
val allRecords = scanAllCallRecords(context)
return allRecords
.filter { record ->
// 多种匹配方式
record.phoneNumber == phoneNumber ||
record.displayName.startsWith("${phoneNumber}_") ||
record.displayName.startsWith(phoneNumber) ||
(record.phoneNumber != null && record.phoneNumber.endsWith(phoneNumber)) ||
(record.phoneNumber != null && phoneNumber.endsWith(record.phoneNumber))
}
.maxByOrNull { it.dateModified }
}
/**
* 从文件名中提取手机号(宽松匹配)
*/
/**
* 从文件名中提取手机号(支持多种格式)
*/
private fun extractPhoneNumberFromFileName(fileName: String): String? {
return try {
// 移除文件扩展名
val nameWithoutExt = fileName.removeSuffix(".mp3")
.removeSuffix(".MP3")
.removeSuffix(".m4a")
.removeSuffix(".M4A")
.removeSuffix(".amr")
.removeSuffix(".AMR")
.removeSuffix(".wav")
.removeSuffix(".WAV")
Log.d(TAG, "解析文件名: $nameWithoutExt")
// 模式1: 手机号(手机号)_时间戳 格式 - 处理 18061119371(18061119371)_2025111762138
val pattern1 = Regex("""^(\d+)\(\1\)_(\d+)$""")
pattern1.find(nameWithoutExt)?.let { match ->
val phoneNumber = match.groupValues[1]
if (phoneNumber.length in 7..15) {
Log.d(TAG, "模式1匹配: 手机号=$phoneNumber")
return phoneNumber
}
}
// 模式2: 手机号(另一个手机号)_时间戳 格式
val pattern2 = Regex("""^(\d+)\((\d+)\)_(\d+)$""")
pattern2.find(nameWithoutExt)?.let { match ->
val phoneNumber1 = match.groupValues[1]
val phoneNumber2 = match.groupValues[2]
// 优先使用第一个手机号
if (phoneNumber1.length in 7..15) {
Log.d(TAG, "模式2匹配: 手机号1=$phoneNumber1, 手机号2=$phoneNumber2")
return phoneNumber1
} else if (phoneNumber2.length in 7..15) {
Log.d(TAG, "模式2匹配: 使用手机号2=$phoneNumber2")
return phoneNumber2
}
}
// 模式3: 手机号_时间戳 格式
val pattern3 = Regex("""^(\d+)_(\d+)$""")
pattern3.find(nameWithoutExt)?.let { match ->
val phoneNumber = match.groupValues[1]
if (phoneNumber.length in 7..15) {
Log.d(TAG, "模式3匹配: 手机号=$phoneNumber")
return phoneNumber
}
}
// 模式4: 纯数字文件名(可能是手机号)
if (nameWithoutExt.all { it.isDigit() } && nameWithoutExt.length in 7..15) {
Log.d(TAG, "模式4匹配: 纯数字手机号=$nameWithoutExt")
return nameWithoutExt
}
// 模式5: 从文件名开头提取连续数字
val pattern5 = Regex("""^(\d+)""")
pattern5.find(nameWithoutExt)?.let { match ->
val digits = match.value
if (digits.length in 7..15) {
Log.d(TAG, "模式5匹配: 开头数字=$digits")
return digits
}
}
// 模式6: 从任意位置提取看起来像手机号的数字序列
val phonePattern = Regex("""\d{7,15}""")
val allMatches = phonePattern.findAll(nameWithoutExt).toList()
if (allMatches.isNotEmpty()) {
// 优先选择长度11位的标准手机号长度
val standardPhone = allMatches.find { it.value.length == 11 }
if (standardPhone != null) {
Log.d(TAG, "模式6匹配: 标准11位手机号=${standardPhone.value}")
return standardPhone.value
}
// 否则选择第一个匹配的
val firstMatch = allMatches.first()
Log.d(TAG, "模式6匹配: 第一个数字序列=${firstMatch.value}")
return firstMatch.value
}
Log.d(TAG, "未找到匹配的手机号格式")
null
} catch (e: Exception) {
Log.w(TAG, "提取手机号失败: $fileName, ${e.message}")
null
}
}
/**
* 从文件名中提取时间戳(如果有的话)
*/
/**
* 从文件名中提取时间戳(支持多种格式)
*/
private fun extractTimestampFromFileName(fileName: String): String? {
return try {
val nameWithoutExt = fileName.removeSuffix(".mp3")
.removeSuffix(".MP3")
.removeSuffix(".m4a")
.removeSuffix(".M4A")
.removeSuffix(".amr")
.removeSuffix(".AMR")
.removeSuffix(".wav")
.removeSuffix(".WAV")
Log.d(TAG, "提取时间戳 from: $nameWithoutExt")
// 匹配13位或14位数字时间戳通常在文件名末尾
val timestampPattern = Regex("""_(\d{13,14})$""")
timestampPattern.find(nameWithoutExt)?.let { match ->
val timestamp = match.groupValues[1]
Log.d(TAG, "找到时间戳: $timestamp")
return timestamp
}
// 如果没有下划线前缀尝试匹配末尾的13-14位数字
val endTimestampPattern = Regex("""(\d{13,14})$""")
endTimestampPattern.find(nameWithoutExt)?.let { match ->
val timestamp = match.value
Log.d(TAG, "找到末尾时间戳: $timestamp")
return timestamp
}
Log.d(TAG, "未找到时间戳")
null
} catch (e: Exception) {
Log.w(TAG, "提取时间戳失败: $fileName, ${e.message}")
null
}
}
/**
* 构建路径查询条件(不限制文件名格式)
*/
private fun buildPathSelectionQuery(): String {
return recordDirs.keys.map {
"${MediaStore.Audio.Media.DATA} LIKE ?"
}.joinToString(" OR ")
}
private fun buildPathSelectionArgs(): Array<String> {
return recordDirs.keys.map { "%$it%" }.toTypedArray()
}
/**
* 根据文件路径获取品牌和目录信息
*/
private fun getBrandAndDirectory(filePath: String): Pair<String, String> {
for ((path, brand) in recordDirs) {
if (filePath.contains(path)) {
return brand to path
}
}
return "未知" to "其他目录"
}
/**
* 获取查询路径信息(用于显示)
*/
fun getQueryPaths(): List<Pair<String, String>> {
return recordDirs.map { (path, brand) ->
brand to path
}
}
/**
* 获取所有支持的目录
*/
fun getSupportedDirectories(): Map<String, String> {
return recordDirs
}
/**
* 检查是否有可访问的录音目录
*/
fun hasAccessibleRecordDirs(): Boolean {
return recordDirs.isNotEmpty()
}
// 格式化文件大小
private fun formatFileSize(size: Long): String {
return when {
size < 1024 -> "$size B"
size < 1024 * 1024 -> "${size / 1024} KB"
else -> "${size / (1024 * 1024)} MB"
}
}
// 格式化日期
private fun formatDate(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return sdf.format(Date(timestamp))
}
/**
* 格式化时间戳为可读格式
*/
fun formatTimestamp(timestamp: String): String {
return try {
if (timestamp.length == 14) {
val sdf = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault())
val date = sdf.parse(timestamp)
SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(date)
} else {
timestamp
}
} catch (e: Exception) {
timestamp
}
}
}

View File

@@ -0,0 +1,150 @@
package com.example.initiateaphonecallapp.util
import com.example.initiateaphonecallapp.data.model.SearchResult
import com.example.initiateaphonecallapp.data.model.SearchState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class FileSearcher {
private val possibleDirs = listOf(
File("/storage/emulated/0/MIUI/sound_recorder/call_rec/"), // 小米
File("/storage/emulated/0/Record/call/"), // 华为
File("/storage/emulated/0/Sounds/CallRecord/"), // 其他华为机型
File("/storage/emulated/0/AudioRecord/") // 其他华为机型
)
suspend fun searchFiles(
fileExtensions: List<String> = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr"),
maxDepth: Int = 10
): SearchState = withContext(Dispatchers.IO) {
try {
val results = mutableListOf<SearchResult>()
val searchedDirs = mutableListOf<String>()
possibleDirs.forEach { dir ->
if (dir.exists() && dir.isDirectory) {
searchedDirs.add(dir.absolutePath)
searchInDirectory(dir, fileExtensions, maxDepth, results)
}
}
SearchState(
isSearching = false,
results = results,
searchedDirectories = searchedDirs
)
} catch (e: Exception) {
SearchState(
isSearching = false,
errorMessage = "搜索失败: ${e.message}"
)
}
}
private fun searchInDirectory(
directory: File,
extensions: List<String>,
maxDepth: Int,
results: MutableList<SearchResult>,
currentDepth: Int = 0
) {
if (currentDepth > maxDepth) return
try {
val files = directory.listFiles() ?: return
files.forEach { file ->
if (file.isDirectory) {
// 递归搜索子目录
searchInDirectory(file, extensions, maxDepth, results, currentDepth + 1)
} else {
// 检查文件扩展名
val fileExtension = file.extension.lowercase()
if (extensions.any { it.lowercase() == ".$fileExtension" }) {
results.add(
SearchResult(
file = file,
directory = file.parent ?: "",
fileName = file.name,
fileSize = file.length(),
lastModified = file.lastModified()
)
)
}
}
}
} catch (e: SecurityException) {
// 处理权限问题
e.printStackTrace()
} catch (e: Exception) {
e.printStackTrace()
}
}
// 可选:搜索特定文件名模式
suspend fun searchFilesWithPattern(
pattern: String,
fileExtensions: List<String> = listOf(".mp3", ".m4a", ".aac", ".wav", ".amr")
): SearchState = withContext(Dispatchers.IO) {
try {
val results = mutableListOf<SearchResult>()
val searchedDirs = mutableListOf<String>()
possibleDirs.forEach { dir ->
if (dir.exists() && dir.isDirectory) {
searchedDirs.add(dir.absolutePath)
searchInDirectoryWithPattern(dir, pattern, fileExtensions, results)
}
}
SearchState(
isSearching = false,
results = results,
searchedDirectories = searchedDirs
)
} catch (e: Exception) {
SearchState(
isSearching = false,
errorMessage = "搜索失败: ${e.message}"
)
}
}
private fun searchInDirectoryWithPattern(
directory: File,
pattern: String,
extensions: List<String>,
results: MutableList<SearchResult>
) {
try {
val files = directory.listFiles() ?: return
files.forEach { file ->
if (file.isDirectory) {
searchInDirectoryWithPattern(file, pattern, extensions, results)
} else {
val fileExtension = file.extension.lowercase()
val fileName = file.name.lowercase()
val searchPattern = pattern.lowercase()
if ((extensions.isEmpty() || extensions.any { it.lowercase() == ".$fileExtension" }) &&
fileName.contains(searchPattern)) {
results.add(
SearchResult(
file = file,
directory = file.parent ?: "",
fileName = file.name,
fileSize = file.length(),
lastModified = file.lastModified()
)
)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,105 @@
// utils/TimeRangeUtils.kt
package com.example.initiateaphonecallapp.utils
import com.example.initiateaphonecallapp.data.model.CallRecordRequest
import java.text.SimpleDateFormat
import java.util.*
object TimeRangeUtils {
private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.getDefault())
/**
* 创建今天的时间范围请求
*/
fun createTodayRequest(): CallRecordRequest {
val calendar = Calendar.getInstance()
// 今天开始时间
val startOfDay = calendar.apply {
set(Calendar.HOUR_OF_DAY, 0)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}.time
// 今天结束时间
val endOfDay = calendar.apply {
set(Calendar.HOUR_OF_DAY, 23)
set(Calendar.MINUTE, 59)
set(Calendar.SECOND, 59)
set(Calendar.MILLISECOND, 999)
}.time
return CallRecordRequest(
startTime = dateFormat.format(startOfDay),
endTime = dateFormat.format(endOfDay)
)
}
/**
* 创建本周的时间范围请求
*/
fun createWeekRequest(): CallRecordRequest {
val calendar = Calendar.getInstance()
// 本周开始时间(周一)
calendar.set(Calendar.DAY_OF_WEEK, Calendar.MONDAY)
calendar.set(Calendar.HOUR_OF_DAY, 0)
calendar.set(Calendar.MINUTE, 0)
calendar.set(Calendar.SECOND, 0)
calendar.set(Calendar.MILLISECOND, 0)
val startOfWeek = calendar.time
// 当前时间作为结束时间
val endOfToday = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 23)
set(Calendar.MINUTE, 59)
set(Calendar.SECOND, 59)
set(Calendar.MILLISECOND, 999)
}.time
return CallRecordRequest(
startTime = dateFormat.format(startOfWeek),
endTime = dateFormat.format(endOfToday)
)
}
/**
* 创建本月的时间范围请求
*/
fun createMonthRequest(): CallRecordRequest {
val calendar = Calendar.getInstance()
// 本月开始时间
calendar.set(Calendar.DAY_OF_MONTH, 1)
calendar.set(Calendar.HOUR_OF_DAY, 0)
calendar.set(Calendar.MINUTE, 0)
calendar.set(Calendar.SECOND, 0)
calendar.set(Calendar.MILLISECOND, 0)
val startOfMonth = calendar.time
// 当前时间作为结束时间
val endOfToday = Calendar.getInstance().apply {
set(Calendar.HOUR_OF_DAY, 23)
set(Calendar.MINUTE, 59)
set(Calendar.SECOND, 59)
set(Calendar.MILLISECOND, 999)
}.time
return CallRecordRequest(
startTime = dateFormat.format(startOfMonth),
endTime = dateFormat.format(endOfToday)
)
}
/**
* 创建自定义时间范围请求
*/
fun createCustomRequest(startDate: Date, endDate: Date): CallRecordRequest {
return CallRecordRequest(
startTime = dateFormat.format(startDate),
endTime = dateFormat.format(endDate)
)
}
}

View File

@@ -0,0 +1,10 @@
<!-- res/drawable/ic_default_avatar.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,5c1.66,0 3,1.34 3,3s-1.34,3 -3,3 -3,-1.34 -3,-3 1.34,-3 3,-3zM12,19.2c-2.5,0 -4.71,-1.28 -6,-3.22 0.03,-1.99 4,-3.08 6,-3.08 1.99,0 5.97,1.09 6,3.08 -1.29,1.94 -3.5,3.22 -6,3.22z"/>
</vector>

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6.62,10.79c1.44,2.83 3.76,5.14 6.59,6.59l2.2,-2.2c0.27,-0.27 0.67,-0.36 1.02,-0.24 1.12,0.37 2.33,0.57 3.57,0.57 0.55,0 1,0.45 1,1v3.5c0,0.55 -0.45,1 -1,1 -9.39,0 -17,-7.61 -17,-17 0,-0.55 0.45,-1 1,-1h3.5c0.55,0 1,0.45 1,1 0,1.25 0.2,2.45 0.57,3.57 0.11,0.35 0.03,0.74 -0.25,1.02l-2.2,2.2z"/>
</vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Some files were not shown because too many files have changed in this diff Show More