记录一下前段时间一个项目解决方案,需求是销售人员在app内或者pc拨打客户电话,拨打完成后上传通话录音。类似于Ec。
实现思路
判断通话自动录音权限是否开启,让用户手动打开通话自动录音功能,然后获取相应的通话录音上传。(自动打开录音开关权限是不对第三方应用开放的,只能手动打开)
用户拨打电话时,定时检测通话记录,当通话记录多出后,说明电话已挂断。开始通过录音文件时间,电话名称等匹配,找到需要的录音文件进行上传。
代码实现
这其中有部分业务代码,主要重点关注 call.js, 这部分代码是实现的核心逻辑。
App.vue
入口页面
<script>
import {
permission
} from '@/utils/router.js'
import {
checkSystemUpdate
} from "@/utils/updateVersion"
// #ifdef APP-PLUS
import {
checkSystemAuth,
onCheckRecord
} from '@/utils/call';
import {
onConnectPc,
onWsSendMsgRunModel
} from '@/utils/PcConnectApp'
// #endif
export default {
globalData: {
show: false,
isDev: process.env.NODE_ENV !== 'production',
},
onLaunch: function() {
permission()
console.log('系统信息', uni.getSystemInfoSync());
// #ifdef APP-PLUS
checkSystemAuth()
onConnectPc()
// #endif
},
onShow: function() {
console.log('App onShow');
if (process.env.NODE_ENV === 'production') {
checkSystemUpdate()
}
onCheckRecord()
this.globalData.show = true
onWsSendMsgRunModel(0)
},
onHide: function() {
console.log('App onHide');
this.globalData.show = false
onWsSendMsgRunModel(1)
}
}
</script>
<style lang="scss">
/* 注意要写在第一行,同时给style标签加入lang="scss"属性 */
@import "@/uni_modules/uview-ui/index.scss";
/* 公共样式 */
@import "@/style/common.scss";
</style>
app权限控制
urils/router.js 此文件主要做权限控制,每次切换页面拉取最新用户数据,获取最新的按钮权限。
// 路由监听
import store from '@/store'
export const permission = function() {
function watchRouter() {
store.dispatch('getUserInfo')
}
uni.addInterceptor('navigateTo', { //监听跳转
success(e) {
watchRouter();
}
})
uni.addInterceptor('redirectTo', { //监听关闭本页面跳转
success(e) {
watchRouter();
}
})
uni.addInterceptor('switchTab', { //监听tabBar跳转
success(e) {
watchRouter();
}
})
uni.addInterceptor('navigateBack', { //监听返回
success(e) {
watchRouter();
}
})
}
检测APP版本
urils/updateVersion.js
此文件主要是用来检测APP版本
/**
* 更新APP版本
*/
import {
baseUrl,
header
} from '@/config/config.js'
import {
closeApp,
downApp
} from '@/utils/appPlus.js'
function showModal() {
uni.showModal({
title: '提示',
content: '请下载最新版本APP',
showCancel: false,
confirmColor: '#1D85EB',
success(res) {
if (res.confirm) {
downApp()
closeApp()
} else {
downApp()
closeApp()
}
}
})
}
/**
* 比较版本号
*/
export function compareVersions(version1, version2) {
const v1 = version1.split(".");
const v2 = version2.split(".");
const len = Math.max(v1.length, v2.length);
for (let i = 0; i < len; i++) {
const num1 = parseInt(v1[i] || 0);
const num2 = parseInt(v2[i] || 0);
if (num1 > num2) {
return 1;
} else if (num1 < num2) {
return -1;
}
}
return 0;
}
/**
* 是否更新
*/
export const getSysAndroidUpdate = async function() {
console.log('getSysAndroidUpdate');
return new Promise((resolve, reject) => {
try {
const res = uni.request({
url: `${baseUrl/***`,
method: 'POST',
data: {
'configKey': '**'
},
header,
success(res) {
console.log('sys.android.update', res);
if (res.statusCode != 200) {
resolve(true)
} else {
const d = res.data
if (d.msg == "true") {
resolve(true)
} else {
resolve(false)
}
}
},
fail(error) {
console.log('fail', error);
resolve(true)
}
})
} catch (e) {
console.log(e);
resolve(true)
}
})
}
/**
* 是否更新版本
*/
export const getSysAndroidVersion = function() {
console.log('getSysAndroidVersion');
return new Promise((resolve, reject) => {
try {
function showModalVersion() {
uni.showModal({
title: '提示',
content: '您的APP版本过旧,请下载更新最新版本的APP',
confirmColor: '#1D85EB',
success(res) {
if (res.confirm) {
downApp()
}
}
})
}
const res = uni.request({
url: `${baseUrl}/**`,
method: 'POST',
data: {
'configKey': '**'
},
header,
success(res) {
console.log('最新系统版本', res);
const d = res.data
const version = d.msg
let storageVersion = plus.runtime.version
console.log('本地版本', storageVersion);
const isUpdate = compareVersions(version, storageVersion)
if (isUpdate) {
// 如果本地存储版本与最新版本不一致则执行下载最新app的逻辑
resolve(true)
showModalVersion()
} else {
resolve(false)
}
},
fail(error) {
console.log('fail', error);
resolve(false)
}
})
} catch (e) {
console.log(e);
rresolve(false)
}
})
}
/**
* 检查是否更新系统版本
*/
export const checkSystemUpdate = async function() {
const sysAndroidUpdate = await getSysAndroidUpdate()
console.log('sysAndroidUpdate', sysAndroidUpdate);
// 如果后台系统已设置为需要更新,那么不再检查系统版本,直接进行更新
if (sysAndroidUpdate) {
showModal()
return
}
getSysAndroidVersion()
}
pc连接app 双向通信
此文件用来pc和app进行通信,保证通话状态统一。
需要引入 device-detector-js - npm (npmjs.com) 获取每个设备型号。
import {
makePhone,
} from "@/utils/call.js";
import {
getVendorCn
} from '@/utils/deviceVendors.js'
import store from "@/store/index.js"
import DeviceDetector from "device-detector-js";
import {
webSocketBaseUrl
} from "@/config/config.js"
// #ifdef APP-PLUS
const systemInfo = uni.getSystemInfoSync()
const deviceBrand = getVendorCn()
console.log(deviceBrand);
const deviceDetector = new DeviceDetector();
const device = deviceDetector.parse(systemInfo.ua)
const model = device.device.model
const modelS = systemInfo.deviceModel
const phoneModel =
`${deviceBrand}-${model || modelS}`
export let phoneMac = systemInfo.deviceId
let socketState = ''
// #endif
/**
* 自动拨打电话
*/
export function onMakePhoneFunc(d) {
if (d.operate == 1) {
const phoneNumber = d.callOn
store.commit('setCurrentTelInfo', {
phoneNumber,
customerId: d.customerId,
callOnCompany: d.callOnCompany,
callOnName: d.callOnName,
identityType: d.identityType
})
// 执行自动拨打电话
makePhone(phoneNumber)
}
}
/**
* PC打电话功能,连接到PC端
*/
export async function onConnectPc() {
// #ifdef APP-PLUS
const userid = uni.getStorageSync('userId')
if (!userid) return
try {
createWebSocket()
const timeout = 1000 * 30
const heartCheck = {
sendTimeoutObj: null,
serverTimeoutObj: null,
// 重置心跳发送
reset: function() {
clearTimeout(this.sendTimeoutObj)
clearTimeout(this.serverTimeoutObj)
},
// 发送心跳
start: function() {
// 定时发送心跳
this.sendTimeoutObj = setTimeout(() => {
// console.log('发送心跳: heart')
uni.sendSocketMessage({
data: 'heart'
})
// 正常来说,当发送完心跳包后,服务端会响应即在onmessage中做出响应,并清除此心跳包发送新的心跳包,
// 如果没有做出响应的,则达到超时时间主动关闭websocket,开始重连
this.serverTimeoutObj = setTimeout(() => {
console.log('主动关闭Socket')
uni.closeSocket()
createWebSocket()
this.reset()
}, timeout)
}, timeout)
}
}
// 创建websocket
function createWebSocket() {
const url = `${webSocketBaseUrl}`
console.log('websocket 创建连接', webSocketBaseUrl);
uni.connectSocket({
url
});
init()
}
// 与WebScket发送第一次消息,建立通道
function onSendWsChannel() {
const message = JSON.stringify({
userid,
source: 'app',
phoneModel,
phoneMac,
type: 1
})
console.log('webSocket第一次发送消息给服务端', `${message}`)
uni.sendSocketMessage({
data: `${message}`
})
}
// 初始化websocket
function init() {
// websocket打开时
console.log('初始化websocket');
uni.onSocketOpen(function() {
console.log('WebSocket 连接打开')
socketState = 'open'
onSendWsChannel()
heartCheck.reset()
heartCheck.start()
});
uni.onSocketClose(function(response) {
console.log('WebSocket 连接关闭', response)
reconnect()
})
// 接收消息
uni.onSocketMessage(function(response) {
console.log('WebSocket 接收到服务器消息:', response)
heartCheck.reset()
heartCheck.start()
if (response.data == 'health') {
// console.log('心跳健康');
} else {
const d = JSON.parse(response.data)
const app = getApp()
if (app.globalData.show) {
onMakePhoneFunc(d)
}
}
})
uni.onSocketError(function(e) {
console.log('WebSocket连接打开失败,请检查!', e);
});
}
let isConnected = false
let reconnectTimeout = null
// 重连
function reconnect() {
if (!uni.getStorageSync('userId')) {
return
}
console.log('准备重连');
// 当前正在操作连接的时候就不进行连接,防止出现重复连接的情况
if (isConnected) return
isConnected = true
reconnectTimeout && clearTimeout(reconnectTimeout)
reconnectTimeout = setTimeout(() => {
heartCheck.reset()
isConnected = false
console.log('开始重连');
createWebSocket()
}, 1000)
}
} catch (e) {
//TODO handle the exception
console.log(e);
}
// #endif
}
/**
* 在手机上打电话,同步给PC端,以展示通话状态
* @param {Object}
*/
export function onSendSocketMessage(operate) {
const userid = uni.getStorageSync('userId')
const callInfo = store.state.currentTelInfo
const message = JSON.stringify({
userid,
operate,
source: 'app',
callOnName: callInfo.callOnName,
identityType: callInfo.identityType,
customerId: callInfo.customerId,
callOn: callInfo.phoneNumber,
callOnCompany: callInfo.callOnCompany,
phoneModel,
phoneMac,
})
console.log('socketState', socketState);
console.log('WebSocket发送消息给PC', message)
let s = setInterval(() => {
if (socketState == 'open') {
clearInterval(s)
uni.sendSocketMessage({
data: `${message}`
});
}
}, 1000)
}
/**
* app切到后台/前台 同步给服务端
* runModel 操作类型 0 前台 1后台
*/
export function onWsSendMsgRunModel(runModel) {
const userid = uni.getStorageSync('userId')
const message = JSON.stringify({
userid,
runModel,
source: 'app',
phoneMac,
})
let s = setInterval(() => {
if (socketState == 'open') {
clearInterval(s)
uni.sendSocketMessage({
data: `${message}`
});
}
}, 2000)
}
录音文件路径获取
urils/deviceVendors.js
// #ifdef APP-PLUS
export const vendor = plus.device.vendor.toLocaleUpperCase()
export const platform = uni.getSystemInfoSync().platform
export const osVersion = Number(uni.getSystemInfoSync().osVersion)
export const romName = uni.getSystemInfoSync().romName
export const recordPath = {
'XIAOMI': '/storage/emulated/0/MIUI/sound_recorder/call_rec/',
'HUAWEI': '/storage/emulated/0/Sounds/CallRecord',
'MEIZU': '/storage/emulated/0/Recorder',
'OPPO': '/storage/emulated/0/Music/Recordings/Call Recordings',
'VIVO': '/storage/emulated/0/Record/Call',
'SAMSUNG': '/storage/emulated/0/Sounds',
'HONOR': "/storage/emulated/0/Sounds/CallRecord"
}
/**
* 获取录音文件路径
*/
export function getRecordPath() {
return recordPath[vendor]
}
export const vendorCn = {
'XIAOMI': '小米',
'HUAWEI': '华为',
'MEIZU': '魅族',
'OPPO': 'OPPO',
'VIVO': 'vivo',
'SAMSUNG': '三星',
'HONOR': "荣耀"
}
export function getVendorCn() {
return vendorCn[vendor]
}
// #endif
app专有方法
urils/appPlus.js
* 针对于APP的一些工具方法封装
*/
import {
vendor,
platform
} from "@/utils/deviceVendors.js";
/**
* 关闭app
*/
export const closeApp = function() {
switch (platform) {
case 'android':
plus.runtime.quit();
break;
case 'ios':
plus.ios.import('UIApplication').sharedApplication().performSelector('exit');
break;
case 'windows': {
location.reload()
break;
}
}
}
/**
* 下载app
*/
export const downApp = function() {
// #ifdef APP-PLUS
plus.runtime.openURL(
'***')
// #endif
// #ifdef H5
location.href = '***'
// #endif
}
/**
* 检查通话自动录音是否开启
*/
export function checkRecord() {
let settingKey = ''
let recordKey = ''
if (vendor == 'XIAOMI') {
settingKey = 'android.provider.Settings$System'
recordKey = 'button_auto_record_call'
} else if (vendor == 'HUAWEI' || vendor == 'HONOR') {
settingKey = 'android.provider.Settings$Secure'
recordKey = 'enable_record_auto_key'
} else if (vendor == 'OPPO') {
settingKey = 'android.provider.Settings$Global'
recordKey = 'oplus_customize_all_call_audio_record'
} else if (vendor == 'VIVO') {
settingKey = 'android.provider.Settings$Global'
recordKey = 'call_record_state_global'
}
const isOpenSetting = plus.android.invoke(settingKey, 'getInt', plus.android.runtimeMainActivity()
.getContentResolver(), recordKey)
return isOpenSetting == 1
}
/**
* 打开通话录音设置
*/
export function onOpenRecordSetting() {
var Intent = plus.android.importClass("android.content.Intent");
var ComponentName = plus.android.importClass('android.content.ComponentName')
var intent = new Intent();
if (vendor == 'XIAOMI') {
intent.setComponent(new ComponentName("com.android.phone",
"com.android.phone.settings.CallRecordSetting"));
}
if (vendor == 'HUAWEI' || vendor == 'HONOR') {
intent.setComponent(new ComponentName("com.android.phone",
"com.android.phone.MSimCallFeaturesSetting"));
}
if (vendor == 'VIVO') {
intent.setComponent(new ComponentName("com.android.phone",
"com.android.incallui.record.CallRecordSetting"));
}
if (vendor == 'OPPO') {
intent.setComponent(new ComponentName("com.android.phone",
"com.android.phone.OplusCallFeaturesSetting"));
}
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction("android.intent.action.VIEW");
var main = plus.android.runtimeMainActivity();
main.startActivity(intent);
}
/**
* 打电话
* @param {String | Number} phoneNumber 电话号码
*/
export const onMakePhone = function(phoneNumber) {
// #ifdef APP-PLUS
// 导入Activity、Intent类
var Intent = plus.android.importClass("android.content.Intent");
var Uri = plus.android.importClass("android.net.Uri");
// 获取主Activity对象的实例
var main = plus.android.runtimeMainActivity();
// 创建Intent
var uri = Uri.parse(`tel:${phoneNumber}`); // 这里可修改电话号码
var call = new Intent("android.intent.action.CALL", uri);
// 调用startActivity方法拨打电话
main.startActivity(call);
// #endif
}
/**
* 唤醒APP
*/
export function onLaunchApp() {
// #ifdef APP-PLUS
var android_main = plus.android.runtimeMainActivity();
var Intent = plus.android.importClass('android.content.Intent');
var intent = new Intent(android_main.getIntent());
intent.setClassName(android_main, 'io.dcloud.PandoraEntryActivity');
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
android_main.startActivity(intent);
// #endif
}
export function getFindContact(phoneNumber) {
plus.contacts.getAddressBook(plus.contacts.ADDRESSBOOK_PHONE, function(addressbook) {
console.log('获取通讯录对象成功')
console.log(addressbook)
addressbook.find(["displayName", "phoneNumbers"], function(contacts) {
console.log("获取联系人成功")
console.log(contacts)
let findItem = contacts.find(item => {
return item.phoneNumbers.some(i => {
const value = uni.$u.trim(i.value, 'all')
return value == phoneNumber
})
})
return findItem
})
})
}
/**
* @param {Array[String]} 收件人信息
* @param {String} body 发送消息内容
*/
export function onSendMessage(to, body) {
// #ifdef APP-PLUS
var msg = plus.messaging.createMessage(plus.messaging.TYPE_SMS);
msg.to = to;
msg.body = body;
plus.messaging.sendMessage(msg, function() {
console.log("Send success!");
}, function() {
console.log("Send failed!");
});
// #endif
}
通话录音检测上传
urils/call.js
需要引入 保活 前台运行 - DCloud 插件市场 插件,保证APP后台运行。
/**
* 有关通话录音相关的方法
*/
import {
baseUrl,
header
} from '@/config/config.js'
import {
onSendSocketMessage,
phoneMac
} from '@/utils/PcConnectApp.js';
import store from "@/store/index.js"
import {
platform,
getRecordPath,
vendor
} from '@/utils/deviceVendors.js';
import {
checkRecord,
onOpenRecordSetting,
onMakePhone
} from '@/utils/appPlus.js';
// #ifdef APP-PLUS
const recordAudioPath = getRecordPath()
const hgService = uni.requireNativePlugin("HG-Background");
const mainActivity = plus.android.runtimeMainActivity()
const packageName = mainActivity.getPackageName()
const Settings = plus.android.importClass('android.provider.Settings')
const Uri = plus.android.importClass('android.net.Uri')
const Intent = plus.android.importClass('android.content.Intent')
// #endif
/**
* 检测app省电策略
*/
export function checkPower() {
const intent = new Intent();
intent.setAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS);
intent.setData(Uri.parse("package:" + packageName));
plus.android.importClass("android.os.PowerManager");
var pm = mainActivity.getSystemService(mainActivity.POWER_SERVICE);
var isIgnoringBatteryOptimizations = pm.isIgnoringBatteryOptimizations(mainActivity.getPackageName());
if (isIgnoringBatteryOptimizations) {
console.log("已忽略电池优化");
} else {
let content = '请允许应用后台运行,否则将会造成通话异常'
if (vendor == 'HUAWEI' || vendor == 'HONOR') {
content = '请点击允许忽略电话优化,否则将会造成通话异常'
} else if (vendor == 'XIAOMI') {
content = '请将省电策略修改为无限制,否则将会造成通话异常'
}
console.log("未忽略电池优化");
uni.showModal({
title: '温馨提示',
content,
showCancel: false,
confirmColor: '#1D85EB',
success: function(res) {
if (res.confirm) {
mainActivity.startActivity(intent);
}
}
})
}
}
/**
* HG插件保活策略
*/
export function onHgPluginKeep() {
hgService.config({
title: "宇成客app",
content: "前台服务运行中",
mode: 1, //0省电模式 1流氓模式
});
hgService.showSafeSetting(); //支持小米,华为,锤子,opp,vivo,三星,乐视,魅族
// if (vendor !== 'XIAOMI') {
// var result = hgService.checkIfLimited();
// if (result.isLimit) {
// hgService.requestIgnoreLimit();
// }
// }
hgService.startService();
var globalEvent = uni.requireNativePlugin('globalEvent');
let counts = 0
globalEvent.addEventListener('doJob', function() {
counts += 1;
console.log("保活任务执行次数:" + counts);
});
}
/**
* app保活策略,主要保证app进程不被杀掉
*/
export function onKeepAlive() {
if (platform === 'android') {
checkPower()
onHgPluginKeep()
// let s = 0
// setInterval(() => {
// console.log('保活:' + s++);
// }, 60000)
}
}
/**
* 检测手机品牌,是否有适配录音文件路径
*/
export function onCheckBrand() {
const isFindBrand = getRecordPath()
if (!isFindBrand) {
uni.showModal({
title: '提示',
content: '未检测到您的手机品牌,请联系管理员适配!',
showCancel: false,
confirmColor: '#1D85EB'
})
}
}
/**
* 检查手机系统权限
*/
export function checkSystemAuth() {
onCheckBrand()
onKeepAlive()
plus.android.requestPermissions(
['android.permission.WRITE_EXTERNAL_STORAGE',
'android.permission.READ_EXTERNAL_STORAGE',
'android.permission.READ_PHONE_STATE',
'android.permission.CALL_PHONE',
'android.permission.READ_CONTACTS',
'android.permission.READ_CALL_LOG',
'android.permission.PROCESS_OUTGOING_CALLS',
'android.permission.WAKE_LOCK',
'android.permission.SYSTEM_ALERT_WINDOW',
'android.permission.FOREGROUND_SERVICE',
'android.permission.BOOT_COMPLETED',
'android.permission.RECEIVE_BOOT_COMPLETED',
'android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS'
],
function(resultObj) {
let result = 0;
for (const i = 0; i < resultObj.granted.length; i++) {
const grantedPermission = resultObj.granted[i];
console.log('已获取的权限:' + grantedPermission);
result = 1
}
for (const i = 0; i < resultObj.deniedPresent.length; i++) {
const deniedPresentPermission = resultObj.deniedPresent[i];
console.log('拒绝本次申请的权限:' + deniedPresentPermission);
result = 0
}
for (const i = 0; i < resultObj.deniedAlways.length; i++) {
const deniedAlwaysPermission = resultObj.deniedAlways[i];
console.log('永久拒绝申请的权限:' + deniedAlwaysPermission);
result = -1
}
if (result !== 1) {
uni.showModal({
title: '提示',
content: '请打开手机系统应用权限!',
showCancel: false,
confirmColor: '#1D85EB'
})
}
},
function(error) {
console.log('申请权限错误:' + error.code + " = " + error.message);
}
);
}
/**
* 添加操作记录
*/
export async function FollowUpLogSave() {
try {
const {
customerId
} = store.state.currentTelInfo
console.log('添加操作记录传参', customerId);
const res = await uni.$u.http.post('/***', {
followUpType: 2, // 2 表示联系记录
customerId,
followModule: 1, // 1 (表示录音文件) 2(表示未接通)
mac: phoneMac
})
console.log('添加操作记录返回结果', res);
if (res.code == 200) {
const followUpId = res.data.followUpId
uni.setStorageSync('followUpId', followUpId)
}
} catch (e) {
//TODO handle the exception
console.log(e);
}
}
/**
* 更新操作记录
*/
export async function FollowUpLogModify(followContent) {
try {
return new Promise(async (resolve) => {
const followUpId = uni.getStorageSync('followUpId')
console.log('更新操作记录传参', {
followUpId,
followContent
});
const res = await uni.$u.http.put('***', {
followUpId, // 日志ID
followContent // 录音文件地址或JSON串
})
console.log('更新操作记录返回结果', res);
if (res.code == 200) {
removeStorageCallInfo()
resolve()
}
})
} catch (e) {
//TODO handle the exception
console.log(e);
reject()
}
}
/**
* 检查是否通话完成
*/
function recordCheck() {
try {
if (platform == 'android') {
const CallLog = plus.android.importClass('android.provider.CallLog');
const Main = plus.android.runtimeMainActivity(); // 此处相当于 context
const Resolver = Main.getContentResolver(); // 获取ContentResolver实例
plus.android.importClass(Resolver);
const makePhoneTime = uni.getStorageSync('makePhoneTime')
console.log('拨出电话的时间戳', makePhoneTime);
const cursor = Resolver.query(CallLog.Calls.CONTENT_URI, null, CallLog.Calls.DATE + '>=' + makePhoneTime, null,
CallLog.Calls.DATE + " DESC");
plus.android.importClass(cursor);
const countOld = cursor.getCount()
// 定时检测,检测到比拨出电话前通话记录条数多出,则证明电话已挂断
const sid = setInterval(() => {
const cursor = Resolver.query(CallLog.Calls.CONTENT_URI, null, CallLog.Calls.DATE + '>=' + makePhoneTime,
null,
CallLog.Calls.DATE + " DESC");
plus.android.importClass(cursor);
const countNew = cursor.getCount()
console.log('拨出前的通话记录条数', countOld);
console.log('拨出后的通话记录条数', countNew);
if (countNew > countOld) {
uni.setStorageSync('callEndTime', Date.now())
clearInterval(sid)
onSendSocketMessage(0)
readFile()
}
cursor.close();
}, 1000 * 10)
cursor.close();
}
} catch (err) {
console.log('callWatch', err)
}
}
/**
* 拨打电话
*/
export async function makePhone(phoneNumber) {
// #ifdef APP-PLUS
const isCheckRecord = checkRecord()
// 未打开通话自动录音不允许打电话
if (!isCheckRecord) {
uni.showModal({
title: '提示',
content: '请打开通话自动录音功能',
showCancel: false,
confirmColor: '#1D85EB',
success(res) {
if (res.confirm) {
onOpenRecordSetting()
}
}
})
return
}
// 拨出电话的时间戳
const makePhoneTime = Date.now()
uni.setStorageSync('makePhoneTime', makePhoneTime)
await FollowUpLogSave()
onMakePhone(phoneNumber)
callWatch()
// #endif
}
/**
* 通话状态监听
*/
export async function callWatch() {
onSendSocketMessage(2)
recordCheck()
}
/**
* 检查是否有未上传的录音
*/
export async function onCheckRecord() {
// 暂时无法处理,无法获取到接听时间,每个品牌的CALL_DATE不一样,MIUI为拨出时间,HONOR为接听时间
}
/**
* 获取录音文件路径
*/
function getRecordFilePath() {
return new Promise((resolve) => {
// console.log('获取录音文件路径', recordAudioPath);
if (!recordAudioPath) {
return
}
var file = plus.android.newObject("java.io.File", recordAudioPath);
if (!file) {
uni.showModal({
title: '提示',
content: '未获取到录音路径,请联系管理员适配设备机型',
showCancel: false,
confirmColor: '#1D85EB'
})
resolve(false)
return
}
resolve(file)
})
}
/**
* 读取录音文件夹,找出需要上传的录音文件
*/
export async function readFile() {
const filePath = await getRecordFilePath()
// console.log('filePath', filePath);
if (!filePath) {
return
}
const files = plus.android.invoke(filePath, "listFiles");
const name = plus.android.invoke(filePath, "getName");
// console.log('录音文件夹名字', name);
// console.log('files', files);
if (!files) {
return
}
const len = files.length;
for (let i = 0; i < len; i++) {
const file = files[i];
// 过滤隐藏文件
if (!plus.android.invoke(file, "isHidden")) {
// 过滤文件夹
if (plus.android.invoke(file, "isDirectory")) {} else {
filterFileUpLoad(file)
}
}
}
}
/**
* 适配录音文件名称
*/
function adapterCallFile(fileName, phoneNumber) {
return new Promise((resolve) => {
let name = uni.$u.trim(fileName, 'all')
if (vendor == 'OPPO') {
// 由于OPPO在拨打联通,移动此类公用电话号码以及手机内已存在的联系人,录音文件名为中文名,不含电话号码
// 需要对此进行特殊处理
plus.contacts.getAddressBook(plus.contacts.ADDRESSBOOK_PHONE, function(addressbook) {
addressbook.find(["displayName", "phoneNumbers"], function(contacts) {
// 如果当前拨打的电话能在手机联系人中找到,那么在录音文件名上加上电话号码。用来适配正则校验
let findItem = contacts.find((item) => {
return item.phoneNumbers.some((i) => {
return i.value === phoneNumber;
});
});
console.log('OPPO 查找到的联系人', findItem);
if (findItem) {
resolve(`${phoneNumber}_${name}`)
} else {
resolve(name)
}
})
})
} else {
resolve(name)
}
})
}
/**
* 筛选出真正需要的录音文件
* @param {Object} file 录音文件
*/
async function filterFileUpLoad(file) {
const fileName = plus.android.invoke(file, "getName");
const lastModified = plus.android.invoke(file, "lastModified");
const fileSuffix = fileName.split('.')[1]
const mediaType = ['mp3', 'm4a', 'flac', 'ogg', 'ape', 'amr', 'wma', 'wav', 'mp4', 'aac']
// 过滤文件格式
if (mediaType.includes(fileSuffix)) {
const callEndTime = uni.getStorageSync('callEndTime')
const errorTime = -20 * 1000 // 误差时间
const minusTime = 10 * 1000 // 由于定时器10s一循环,需要考虑定时器消耗的时间,最多消耗掉10s
// console.log('callEndTime', callEndTime);
if (vendor == 'HUAWEI') {
// HUAWEI系统的lastModified 为创建时间
const audio = uni.createInnerAudioContext()
console.log('录音文件路径', `${recordAudioPath}/${fileName}`);
audio.src = `${recordAudioPath}/${fileName}`
audio.onCanplay(async () => {
const audioTime = audio.duration
// 录音完成时间=创建时间+录音持续时间
const lastModifiedHuaWei = lastModified + (audioTime * 1000)
const diffTime = callEndTime - lastModifiedHuaWei;
// 如果在拨出时间后,并且在拨出时间后的10秒内,可以认为是我们需要的录音文件
// console.log('HUAWEI creationTime', lastModified);
// console.log('HUAWEI diffTime', diffTime);
// console.log('HUAWEI fileName', fileName);
if (diffTime < minusTime && diffTime > errorTime) {
// 由于要读取录音时长,此事件为异步。所以要等待录音读取完毕后再执行上传
filterNameFile()
}
})
} else {
// 其它系统
const diffTime = callEndTime - lastModified
// console.log('diffTime', diffTime);
// console.log('fileName', fileName);
if (diffTime < minusTime && diffTime > errorTime) {
filterNameFile()
}
}
}
async function filterNameFile() {
// 员工当前拨打的客户电话号码
const currentMakePhoneCall = store.state.currentTelInfo.phoneNumber
const makeCall = uni.$u.trim(currentMakePhoneCall, 'all')
console.log('员工当前拨打的电话', makeCall);
const fileNameAdapter = await adapterCallFile(fileName, makeCall)
console.log('录音文件名', fileNameAdapter);
const callRegex = new RegExp(`${makeCall}(?!=\d)`)
const fileCallRegexResult = fileNameAdapter.match(callRegex)
console.log('电话号码匹配的正则', fileCallRegexResult);
if (fileCallRegexResult) {
// 通话-已接通
// 如果录音文件电话号码与拨打电话号码一致,可以确定是需要上传的电话录音文件
const fileNameCall = fileCallRegexResult[0]
console.log('录音文件电话号码', fileNameCall);
if (fileNameCall == makeCall) {
// 准备进行上传文件
const fileName = fileNameAdapter.split('.')[0]
const fileSuffix = fileNameAdapter.split('.')[1]
const pathTo =
`/storage/emulated/0/music/${fileName}_${Date.now()}.${fileSuffix}`
const backupFile = plus.android.newObject("java.io.File",
pathTo);
const getCanonicalPath = plus.android.invoke(backupFile,
"createNewFile");
let input
let output
try {
console.log('pathTo', pathTo);
input = plus.android.newObject("java.io.FileInputStream", file);
output = plus.android.newObject("java.io.FileOutputStream",
backupFile);
const channel1 = plus.android.invoke(input, "getChannel");
const channel2 = plus.android.invoke(output, "getChannel");
const size = plus.android.invoke(channel1, "size");
plus.android.invoke(channel1, "transferTo", 0, size, channel2);
const uploadFileSrc = await uploadFile(pathTo)
if (uploadFileSrc) {
addFile(file, uploadFileSrc, backupFile)
}
} catch (e) {
console.log('readFile', e);
} finally {
plus.android.invoke(input, "close")
plus.android.invoke(output, "close")
}
} else {
// 通话-未接通
removeStorageCallInfo()
}
} else {
removeStorageCallInfo()
}
}
}
/**
* 清空已处理(成功或者失败)的通话信息
*/
function removeStorageCallInfo() {
// 清空拨出电话时间戳,操作日志ID
uni.removeStorageSync('makePhoneTime')
uni.removeStorageSync('followUpId')
uni.removeStorageSync('callEndTime')
// 清空当前拨打客户的信息
store.commit('setCurrentTelInfo', {})
console.log('清空拨出电话时间戳,操作日志ID');
}
/**
* 上传录音文件
* @param {String} filePath 录音文件路径
*/
function uploadFile(filePath) {
return new Promise((resolve, reject) => {
function uploadFail() {
uni.hideLoading()
reject()
uni.showModal({
content: '录音文件上传失败,请联系管理员处理',
title: '提示',
showCancel: false,
confirmColor: '#1D85EB'
})
}
uni.showLoading({
mask: true,
title: '录音上传中..'
})
try {
setTimeout(() => {
const userId = store.state.userInfo?.user?.userId
const task = plus.uploader.createUpload(`${baseUrl}/***`, {
method: "POST",
priority: 100,
retry: 2,
retryInterval: 10,
timeout: 0
},
function(t, status) {
// 上传完成
if (status == 200) {
const data = JSON.parse(t.responseText)
console.log('data', data);
if (data.code == 200) {
console.log('录音文件上传成功');
resolve(data.data)
} else {
uploadFail()
}
} else {
uploadFail()
reject(false)
}
}
);
task.addFile(filePath, {
key: "file"
});
task.addData("userId", userId);
task.setRequestHeader('Request-From', header['Request-From'])
task.setRequestHeader('token', uni.getStorageSync('token'))
task.addEventListener("statechanged", (upload, status) => {
switch (upload.state) {
case 1: // 上传任务开始请求
break
case 2: // 上传任务请求已经建立
break
case 3: // 上传任务提交数据,监听 statechanged 事件时可多次触发此状态。(重点)
// uploadedSize表示当前已经上传了的数据大小,totalSize表示文件总大小,单位是字节b
// console.log('上传进度', parseInt(100 * upload.uploadedSize / upload
// .totalSize))
break
case 4: // 上传任务已完成, 无论成功或失败都会执行到 4 这里
if (status === 200) {
// 上传成功
} else {
// 上传失败
uploadFail()
reject(false)
}
}
});
task.start();
}, 0)
} catch (e) {
//TODO handle the exception
console.log('uploadFile Error', e);
uploadFail()
reject(false)
}
})
}
/**
* 添加录音文件
* @param {Object} file 原始录音文件
* @param {Object} fileSrc 上传后的录音文件地址
* @param {Object} backupFile 移动到公共目录下的录音文件
*/
export async function addFile(file, fileSrc, backupFile) {
return new Promise((resolve, reject) => {
function addFail() {
uni.hideLoading()
uni.showModal({
content: '录音文件添加失败,请联系管理员处理',
title: '提示',
showCancel: false,
confirmColor: '#1D85EB'
})
reject()
}
try {
const {
phoneNumber: callOn,
callOnName,
identityType,
customerId,
callOnCompany
} = store.state.currentTelInfo
const userInfo = store.state.userInfo
const audio = uni.createInnerAudioContext()
audio.src = fileSrc
console.log('audio.src', audio.src);
audio.onCanplay(async () => {
const audioTime = parseInt(audio.duration)
// 添加录音
const params = {
userId: userInfo?.user?.userId,
userName: userInfo?.user?.userName,
deptId: userInfo?.user?.deptId,
deptName: userInfo?.user?.dept?.deptName,
parentDeptId: userInfo?.user?.dept?.parentId,
parentDeptName: userInfo?.user?.dept?.parentName,
file: fileSrc,
audioTime,
callOnName,
identityType,
customerId,
callOn,
callOnCompany
}
console.log('添加录音传的参数', params);
const res = await uni.$u.http.post('***', params)
console.log('添加录音返回的结果', res);
if (res.code == 200) {
FollowUpLogModify(JSON.stringify(res.data))
// 上传成功删除录音文件
console.log('上传成功删除录音文件');
plus.android.invoke(file, 'delete')
plus.android.invoke(backupFile, 'delete')
uni.hideLoading()
uni.showToast({
title: '录音上传成功'
})
resolve()
} else {
addFail()
reject()
}
})
} catch (e) {
console.log('addFile Error', e);
addFail()
reject()
}
})
}
uniapp配置
权限配置表
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CALL_PHONE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
<uses-permission android:name="android.permission.FLASHLIGHT"/>
<uses-permission android:name="android.permission.GET_ACCOUNTS"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MOUNT_UNMOUNT_FILESYSTEMS"/>
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_LOGS"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
<uses-permission android:name="android.permission.WRITE_SMS"/>
云端插件,具体链接 保活 前台运行 - DCloud 插件市场
这里要选择28, 否则华为鸿蒙系统会出问题。
一些其它方案
通话状态监听2
还有一种监听通话状态的方案,就是可以直接读取用户的通话状态。但是在小米手机上需要额外设置一下,允许读取通话状态。这个也无法检测该权限去给用户提示,所以最后被放弃了。
/**
* 通话状态监听
*/
export function callWatch() {
try {
var isAlreadyRead = false // 读取录音文件状态, false未读取
if (uni.getSystemInfoSync().platform == 'android') { //Android
var main = plus.android.runtimeMainActivity();
var Context = plus.android.importClass("android.content.Context");
plus.android.importClass("android.telephony.TelephonyManager");
var telephonyManager = plus.android.runtimeMainActivity().getSystemService(Context
.TELEPHONY_SERVICE);
var phonetype = telephonyManager
.getCallState(); // 通话状态: 0:空闲状态 1:拨打状态 2:结束通话
var receiver = plus.android.implements('io.dcloud.android.content.BroadcastReceiver', {
onReceive: function(context, intent) {
// console.log('context', context);
plus.android.importClass(intent);
var telephonyManager = plus.android.importClass(
"android.telephony.TelephonyManager");
var telephonyManager = plus.android.runtimeMainActivity().getSystemService(
Context
.TELEPHONY_SERVICE);
var phonetype = telephonyManager
.getCallState(); // 通话状态: 0:空闲状态 1:拨打状态 2:结束通话
// 结束通话后,并且通话状态变更为空闲状态,开始进行录音文件的上传
console.log('通话状态', phonetype, isAlreadyRead);
if (phonetype == 2) {
isAlreadyRead = true
onSendSocketMessage(2)
} else if (phonetype == 0 && isAlreadyRead) {
isAlreadyRead = false
console.log('开始读取录音文件');
onSendSocketMessage(0)
readFile()
}
}
});
var IntentFilter = plus.android.importClass('android.content.IntentFilter');
var filter = new IntentFilter();
filter.addAction(telephonyManager.ACTION_PHONE_STATE_CHANGED);
main.registerReceiver(receiver, filter);
} else if (uni.getSystemInfoSync().platform == 'ios') { //ios
// const callstatus = false
// const CTCall = plus.ios.importClass('CTCall');
// const CTCallCenter = plus.ios.importClass('CTCallCenter');
// const center = new CTCallCenter();
// center.init()
// center.setCallEventHandler(function() {
// callstatus = !callstatus
// })
}
} catch (err) {
console.log('callWatch', err)
}
}
背景音乐保活
还有一种保活的方案,就是播放背景音乐,这个会影响用户手机的使用。可以酌情考虑。
/**
* 背景音乐保活策略
*/
export const backgroundAudio = {
innerAudioContext: null,
onPlay() {
const innerAudioContext = this.innerAudioContext = uni.createInnerAudioContext();
innerAudioContext.autoplay = true;
innerAudioContext.volume = 1
innerAudioContext.loop = true
innerAudioContext.src =
'https://bjetxgzv.cdn.bspapp.com/VKCEYUGU-hello-uniapp/2cc220e0-c27a-11ea-9dfb-6da8e309e0d8.mp3';
innerAudioContext.onPlay(() => {
console.log('背景音乐保活:开始播放');
});
innerAudioContext.onError((res) => {
console.log(res.errMsg);
console.log(res.errCode);
});
},
onPause() {
const innerAudioContext = this.innerAudioContext;
if (innerAudioContext) {
innerAudioContext.pause()
innerAudioContext.onPause(() => {
console.log('背景音乐保活:暂停');
});
}
}
}
检测因意外未上传的录音
这个没有完全实现,上面在代码注释中也说了问题所在。在这里也简单提供一个思路,如果能解决问题该方案也是可用的。
思路就是在用户拨打电话时,存储拨打电话的信息(拨出时间,电话号)。从通话记录找到电话接听时间,并以此推算出录音文件的创建时间,找到合适的文件。 以下为简单的代码实现,可供参考:
call.js
/**
* 获取接听电话时间戳
*/
export function getOnthePhoneTime(makePhoneTime, phoneNumber) {
if (platform == 'android') {
return new Promise((resolve) => {
let onThePhoneTime
const CallLog = plus.android.importClass('android.provider.CallLog');
const Main = plus.android.runtimeMainActivity(); // 此处相当于 context
const Resolver = Main.getContentResolver(); // 获取ContentResolver实例
plus.android.importClass(Resolver);
console.log('拨出电话时间', makePhoneTime);
const cursor = Resolver.query(CallLog.Calls.CONTENT_URI, null, CallLog.Calls.DATE + '>=' + makePhoneTime -
3000,
null,
CallLog.Calls.DATE + "ASC");
plus.android.importClass(cursor);
// 从通话记录找到电话接听时间
cursor.moveToNext()
const callsNUMBER = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER));
console.info('callsNUMBER', callsNUMBER)
const callsDATE = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE));
console.info('callsDATE', callsDATE)
onThePhoneTime = callsDATE
cursor.close();
resolve(onThePhoneTime)
})
}
}
/**
* 检查是否有录音文件上传
*/
export async function onCheckRecord() {
const recordCall = cache.local.getJSON('recordCall')
console.log('是否有录音文件上传', recordCall);
if (recordCall && recordCall.length > 0) {
for (var i = 0; i < recordCall.length; i++) {
const item = recordCall[i]
item.onThePhoneTime = await getOnthePhoneTime(item.makePhoneTime, item.telInfo.phoneNumber)
console.log('makePhoneTime', item.makePhoneTime);
console.log('onThePhoneTime', item.onThePhoneTime);
}
console.log('recordCall', recordCall);
cache.local.setJSON('recordCall', recordCall)
readFile()
}
}
/**
* 筛选出真正需要的录音文件
* @param {Object} file 录音文件
*/
async function filterFileUpLoad(file) {
const fileName = plus.android.invoke(file, "getName");
const lastModified = plus.android.invoke(file, "lastModified");
const fileSuffix = fileName.split('.')[1]
const mediaType = ['mp3', 'm4a', 'flac', 'ogg', 'ape', 'amr', 'wma', 'wav', 'mp4', 'aac']
// 过滤文件格式
if (mediaType.includes(fileSuffix)) {
const recordCall = cache.local.getJSON('recordCall')
for (var i = 0; i < recordCall.length; i++) {
const callInfo = recordCall[i]
const onThePhoneTime = callInfo.onThePhoneTime
const errorTime = 10 * 1000 // 误差时间
console.log('onThePhoneTime', onThePhoneTime);
if (vendor == 'HUAWEI') {
const diffTime = lastModified - onThePhoneTime
console.log('HUAWEI creationTime', lastModified);
console.log('HUAWEI diffTime', diffTime);
console.log('HUAWEI fileName', fileName);
// HUAWEI系统的lastModified 为创建时间
if (diffTime > 0 && diffTime < errorTime) {
filterNameFile(callInfo)
} else {
removeStorageCallInfo(callInfo)
}
} else {
// 其它系统
const audio = uni.createInnerAudioContext()
console.log('录音文件路径', `${recordAudioPath}/${fileName}`);
audio.src = `${recordAudioPath}/${fileName}`
audio.onCanplay(async () => {
const audioTime = audio.duration
// 录音创建时间=完成时间-录音持续时间
const creationTime = lastModified - (audioTime * 1000)
const diffTime = creationTime - onThePhoneTime;
// 如果在拨出时间后,并且在拨出时间后的30秒内,可以认为是我们需要的录音文件
console.log('creationTime', creationTime);
console.log('diffTime', diffTime);
console.log('fileName', fileName);
if (diffTime > 0 && diffTime < errorTime) {
// 由于要读取录音时长,此事件为异步。所以要等待录音读取完毕后再执行上传
filterNameFile(callInfo)
} else {
removeStorageCallInfo(callInfo)
}
})
}
}
}
async function filterNameFile(callInfo) {
// 员工当前拨打的客户电话号码
const currentMakePhoneCall = callInfo.telInfo.phoneNumber
const makeCall = uni.$u.trim(currentMakePhoneCall, 'all')
console.log('员工当前拨打的电话', makeCall);
const fileNameAdapter = await adapterCallFile(fileName, makeCall)
console.log('录音文件名', fileNameAdapter);
const callRegex = new RegExp(`${makeCall}(?!=\d)`)
const fileCallRegexResult = fileNameAdapter.match(callRegex)
console.log('电话号码匹配的正则', fileCallRegexResult);
if (fileCallRegexResult) {
// 通话-已接通
// 如果录音文件电话号码与拨打电话号码一致,可以确定是需要上传的电话录音文件
const fileNameCall = fileCallRegexResult[0]
console.log('录音文件电话号码', fileNameCall);
if (fileNameCall == makeCall) {
// 准备进行上传文件
const fileName = fileNameAdapter.split('.')[0]
const fileSuffix = fileNameAdapter.split('.')[1]
const pathTo =
`/storage/emulated/0/music/${fileName}_${Date.now()}.${fileSuffix}`
const backupFile = plus.android.newObject("java.io.File",
pathTo);
const getCanonicalPath = plus.android.invoke(backupFile,
"createNewFile");
let input
let output
try {
console.log('pathTo', pathTo);
input = plus.android.newObject("java.io.FileInputStream", file);
output = plus.android.newObject("java.io.FileOutputStream",
backupFile);
const channel1 = plus.android.invoke(input, "getChannel");
const channel2 = plus.android.invoke(output, "getChannel");
const size = plus.android.invoke(channel1, "size");
plus.android.invoke(channel1, "transferTo", 0, size, channel2);
const uploadFileSrc = await uploadFile(pathTo)
if (uploadFileSrc) {
addFile(file, uploadFileSrc, callInfo, backupFile)
}
} catch (e) {
console.log('readFile', e);
} finally {
plus.android.invoke(input, "close")
plus.android.invoke(output, "close")
}
} else {
// 通话-未接通
removeStorageCallInfo(callInfo)
}
} else {
removeStorageCallInfo(callInfo)
}
}
}
/**
* 清空已处理(成功或者失败)的通话信息
*/
function removeStorageCallInfo(callInfo) {
let recordCall = cache.local.getJSON('recordCall')
let callList = []
for (var i = 0; i < recordCall.length; i++) {
const item = recordCall[i]
const makePhoneTime = item.makePhoneTime
const timeout = 1000 * 60 * 60 * 24 * 30
const now = Date.now()
const isTimeout = now - makePhoneTime <= timeout
const isExist = item.makePhoneTime == callInfo.makePhoneTime && item.telInfo.phoneNumber == callInfo.telInfo
.phoneNumber && item.followUpId == callInfo.followUpId
// 当每一项通话信息与校验过的通话信息比对,如果不一致添加,一致删除
// 当存储的通话信息已经超过30天,那么可以认为此项信息不需要上传了(主要针对于:双卡时会选择卡1,卡2,此时点击取消无法检测,只能每次都存储通话信息,但存储过多会影响性能,所以在此做了超时判断,超过30天的就删除掉不再比对了)
if (!isExist && isTimeout) {
callList.push(callInfo)
}
}
console.log('清空通话信息后', callList);
cache.local.setJSON('recordCall', callList)
}