소스 검색

feat 日志FTP上传

RobinTan1024 4 년 전
부모
커밋
6955cd4922

+ 4 - 0
app/src/main/java/com/doverfuelingsolutions/issp/api/dto/DFSResult.kt

@@ -16,6 +16,10 @@ class DFSResult<T> private constructor(
             return DFSResult(true, obj, "success")
         }
 
+        fun <T> success(obj: T, msg: String): DFSResult<T> {
+            return DFSResult(true, obj, msg)
+        }
+
 
 
         fun <T> fail(msg: String): DFSResult<T> {

+ 116 - 0
app/src/main/java/com/doverfuelingsolutions/issp/api/ftp/WayneFTPClient.kt

@@ -0,0 +1,116 @@
+package com.doverfuelingsolutions.issp.api.ftp
+
+import com.doverfuelingsolutions.issp.R
+import com.doverfuelingsolutions.issp.api.WayneApiConfig
+import com.doverfuelingsolutions.issp.api.dto.DFSResult
+import com.doverfuelingsolutions.issp.utils.DeviceUtil
+import com.doverfuelingsolutions.issp.utils.StringUtil
+import com.doverfuelingsolutions.issp.utils.TimeUtil
+import com.doverfuelingsolutions.issp.utils.ZipUtils
+import com.doverfuelingsolutions.issp.utils.log.DFSLog
+import com.doverfuelingsolutions.issp.utils.thread.ThreadUtil
+import kotlinx.coroutines.GlobalScope
+import kotlinx.coroutines.launch
+import org.apache.commons.net.ftp.FTPClient
+import org.apache.commons.net.ftp.FTPReply
+import java.io.File
+import java.util.*
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+/**
+ * Wayne FTP客户端工具类
+ *
+ * @param host     FTP服务器域名或者IP地址
+ * @param username 账号
+ * @param password 密码
+ */
+class WayneFTPClient(private val host: String, private val username: String, private val password: String) {
+
+    private val clientFTP = FTPClient()
+    private var isConnected = false
+
+    /**
+     * 「协程挂起函数」连接登录到FTP服务器
+     *
+     * @return 连接是否成功
+     */
+    suspend fun connect() = suspendCoroutine<Boolean> {
+        ThreadUtil.io {
+            try {
+                clientFTP.connect(host)
+                if (!FTPReply.isPositiveCompletion(clientFTP.replyCode)) {
+                    it.resume(false)
+                    return@io
+                }
+
+                val success = clientFTP.login(username, password)
+                if (!success) {
+                    it.resume(false)
+                    clientFTP.disconnect()
+                    return@io
+                }
+
+                isConnected = true
+                it.resume(true)
+            } catch (e: Exception) {
+                DFSLog.e(e)
+            }
+        }
+    }
+
+    /**
+     * 「协程挂起函数」上传文件到FTP服务器,如果没有连接则自动连接
+     *
+     * @param file 待上传的本地文件
+     * @param remoteFolder 上传文件的服务器目录
+     * @param fileType 文件类型,默认值是 FTPClient.BINARY_FILE_TYPE(二进制),其他可选:FTPClient.ASCII_FILE_TYPE(文本)、FTPClient.EBCDIC_FILE_TYPE、FTPClient.LOCAL_FILE_TYPE
+     */
+    suspend fun uploadFile(file: File, remoteFolder: String, fileType: Int = FTPClient.BINARY_FILE_TYPE) = suspendCoroutine<Boolean> {
+        GlobalScope.launch {
+            if (!isConnected && !connect()) {
+                it.resume(false)
+                return@launch
+            }
+
+            ThreadUtil.io {
+                clientFTP.setFileType(fileType)
+                clientFTP.makeDirectory(remoteFolder)
+                val resultChangeDirectory = clientFTP.changeWorkingDirectory(remoteFolder)
+                if (!resultChangeDirectory) {
+                    it.resume(false)
+                    DFSLog.e("FTP服务器目录「$remoteFolder」创建失败")
+                    close()
+                    return@io
+                }
+                val inputStream = file.inputStream()
+                try {
+                    val resultAppend = clientFTP.appendFile(file.name, inputStream)
+                    it.resume(resultAppend)
+                } catch (e: Exception) {
+                    it.resume(false)
+                    DFSLog.e(e)
+                } finally {
+                    inputStream.close()
+                }
+            }
+        }
+    }
+
+    /**
+     * 「协程挂起函数」上传文件到FTP服务器,如果没有连接则自动连接
+     *
+     * @param filePath 待上传的本地文件路径
+     * @param remoteFolder 上传文件的服务器目录
+     * @param fileType 文件类型,默认值是 FTPClient.BINARY_FILE_TYPE(二进制),其他可选:FTPClient.ASCII_FILE_TYPE(文本)、FTPClient.EBCDIC_FILE_TYPE、FTPClient.LOCAL_FILE_TYPE
+     */
+    suspend fun uploadFile(filePath: String, remoteFolder: String, fileType: Int = FTPClient.BINARY_FILE_TYPE) = uploadFile(File(filePath), remoteFolder, fileType)
+
+    /**
+     * 关闭FTP客户端
+     */
+    fun close() {
+        isConnected = false
+        clientFTP.disconnect()
+    }
+}

+ 2 - 1
app/src/main/java/com/doverfuelingsolutions/issp/utils/TimeUtil.kt

@@ -8,7 +8,8 @@ object TimeUtil {
 
     private val defaultFormatter: SimpleDateFormat by lazyOf(SimpleDateFormat(StringUtil.get(R.string.format_date), Locale.CHINA))
 
-    private fun dateFormat(date: Date) = defaultFormatter.format(date)
+    fun dateFormat(): String = defaultFormatter.format(Date())
+    fun dateFormat(date: Date): String = defaultFormatter.format(date)
     private fun dateFormat(date: Date, format: String) = SimpleDateFormat(format, Locale.CHINA).format(date)
 
     fun dateFormat(mills: Long): String = defaultFormatter.format(Date(mills))

+ 131 - 0
app/src/main/java/com/doverfuelingsolutions/issp/utils/ZipUtil.kt

@@ -0,0 +1,131 @@
+package com.doverfuelingsolutions.issp.utils
+
+import com.doverfuelingsolutions.issp.utils.log.DFSLog
+import java.io.File
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.OutputStream
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+
+object ZipUtils {
+
+    private const val BUFFER_SIZE = 2048
+
+    /**
+     * 压缩成ZIP方法
+     *
+     * @param srcDir           待压缩目录或者文件路径
+     * @param targetFile       压缩输出文件路径
+     * @param KeepDirStructure 是否保留原来的目录结构,true:保留目录结构;false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
+     * @throws RuntimeException 压缩失败会抛出运行时异常
+     */
+    fun buildZip(srcDir: String, targetFile: String, KeepDirStructure: Boolean): Boolean {
+        val out = File(targetFile).outputStream()
+        var zos: ZipOutputStream? = null
+        try {
+            val sourceFile = File(srcDir)
+            zos = ZipOutputStream(out)
+            compress(sourceFile, zos, sourceFile.name, KeepDirStructure)
+        } catch (e: Exception) {
+            DFSLog.e(e)
+            return false
+        } finally {
+            try {
+                zos?.close()
+                out.close()
+            } catch (e: IOException) {
+                DFSLog.e(e)
+            }
+        }
+
+        return true
+    }
+
+    /**
+     * 压缩成ZIP 方法
+     *
+     * @param srcFiles 需要压缩的文件列表 58
+     * @param out      压缩文件输出流 59
+     * @throws RuntimeException 压缩失败会抛出运行时异常 60
+     */
+    @Throws(RuntimeException::class)
+    fun toZip(srcFiles: List<File>, out: OutputStream?) {
+        val start = System.currentTimeMillis()
+        var zos: ZipOutputStream? = null
+        try {
+            zos = ZipOutputStream(out)
+            for (srcFile in srcFiles) {
+                val buf = ByteArray(BUFFER_SIZE)
+                zos.putNextEntry(ZipEntry(srcFile.name))
+                read(zos, srcFile, buf)
+            }
+            val end = System.currentTimeMillis()
+            println("压缩完成,耗时:" + (end - start) + " ms")
+        } catch (e: Exception) {
+            throw RuntimeException("zip error from ZipUtils", e)
+        } finally {
+            if (zos != null) {
+                try {
+                    zos.close()
+                } catch (e: IOException) {
+                    e.printStackTrace()
+                }
+            }
+        }
+    }
+
+    /**
+     * 递归压缩方法
+     *
+     * @param sourceFile       源文件
+     * @param zos              zip输出流
+     * @param name             压缩后的名称
+     * @param KeepDirStructure 是否保留原来的目录结构,true:保留目录结构
+     * false:所有文件跑到压缩包根目录下(注意:不保留目录结构可能会出现同名文件,会压缩失败)
+     * @throws Exception
+     */
+    @Throws(Exception::class)
+    private fun compress(sourceFile: File, zos: ZipOutputStream, name: String, KeepDirStructure: Boolean) {
+        val buf = ByteArray(BUFFER_SIZE)
+        if (sourceFile.isFile) {
+            // 向zip输出流中添加一个zip实体,构造器中name为zip实体的文件的名字
+            zos.putNextEntry(ZipEntry("$name/$name"))
+            // copy文件到zip输出流中
+            read(zos, sourceFile, buf)
+        } else {
+            val listFiles = sourceFile.listFiles()
+            if (listFiles == null || listFiles.isEmpty()) {
+                // 需要保留原来的文件结构时,需要对空文件夹进行处理
+                if (KeepDirStructure) {
+                    // 空文件夹的处理
+                    zos.putNextEntry(ZipEntry("$name/"))
+                    // 没有文件,不需要文件的copy
+                    zos.closeEntry()
+                }
+            } else {
+                for (file in listFiles) {
+                    // 判断是否需要保留原来的文件结构
+                    if (KeepDirStructure) {
+                        // 注意:file.getName()前面需要带上父文件夹的名字加一斜杠,
+                        // 不然最后压缩包中就不能保留原来的文件结构,即:所有文件都跑到压缩包根目录下了
+                        compress(file, zos, name + "/" + file.name, KeepDirStructure)
+                    } else {
+                        compress(file, zos, file.name, KeepDirStructure)
+                    }
+                }
+            }
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun read(zos: ZipOutputStream, srcFile: File, buf: ByteArray) {
+        var len: Int
+        val inputStream = FileInputStream(srcFile)
+        while (inputStream.read(buf).also { len = it } != -1) {
+            zos.write(buf, 0, len)
+        }
+        zos.closeEntry()
+        inputStream.close()
+    }
+}

+ 2 - 2
app/src/main/java/com/doverfuelingsolutions/issp/utils/log/DFSLog.kt

@@ -9,10 +9,10 @@ class DFSLog {
         const val TAG = "robin"
         var console = true
         var file = true
-        var saveDays = 30
+        var saveDays = 7
 
         @Suppress("DEPRECATION")
-        var folder = "${Environment.getExternalStorageDirectory().absolutePath}/dfs/log"
+        val folder = "${Environment.getExternalStorageDirectory().absolutePath}/dfs/log"
 
         fun v(vararg contents: Any?) {
             DFSLogger.log(DFSLogType.Verbose, *contents)

+ 13 - 4
app/src/main/java/com/doverfuelingsolutions/issp/utils/log/DFSLogger.kt

@@ -52,6 +52,19 @@ object DFSLogger {
             if (!folder.exists() || !folder.isDirectory) folder.mkdirs()
             val mFile = File("${DFSLog.folder}/${date}")
             if (!mFile.isFile) {
+                // delete old file
+                val dateOld = formatter
+                    .format(Date(today.time - 86400000L * DFSLog.saveDays))
+                    .substring(0..9)
+                    .replace("-", "")
+                    .toInt()
+                File(DFSLog.folder).listFiles()?.forEach {
+                    val numberItem = it.name.replace("-", "").toIntOrNull()
+                    if (numberItem == null || numberItem < dateOld) {
+                        it.delete()
+                    }
+                }
+
                 mFile.createNewFile()
                 mFile.writeText(
                     "************* Log Info ****************$LINE_BR" +
@@ -61,10 +74,6 @@ object DFSLogger {
                             "Android SDK: ${Build.VERSION.SDK_INT}$LINE_BR" +
                             "************* Log Info ****************$LINE_BR$LINE_BR"
                 )
-                // delete old file
-                val dateOld = formatter.format(Date(today.time - 86400000L * DFSLog.saveDays)).substring(0..9)
-                val oldFile = File("${DFSLog.folder}/${dateOld}")
-                oldFile.deleteOnExit()
             }
             mFile.appendText("$time [${type.value}] $content$LINE_BR")
         }

+ 2 - 2
app/src/main/java/com/doverfuelingsolutions/issp/utils/thread/ThreadUtil.kt

@@ -7,9 +7,9 @@ import java.util.concurrent.TimeUnit
 object ThreadUtil {
 
     private val logExecutor =
-        ThreadPoolExecutor(1, 1, 10, TimeUnit.SECONDS, LinkedBlockingQueue(100), DFSThreadFactory("log"))
+        ThreadPoolExecutor(1, 1, 10, TimeUnit.SECONDS, LinkedBlockingQueue(200), DFSThreadFactory("log"))
     private val ioExecutor =
-        ThreadPoolExecutor(3, 5, 0, TimeUnit.MILLISECONDS, LinkedBlockingQueue(200), DFSThreadFactory("io"))
+        ThreadPoolExecutor(5, 10, 0, TimeUnit.MILLISECONDS, LinkedBlockingQueue(200), DFSThreadFactory("io"))
 
     fun log(runnable: Runnable) = logExecutor.execute(runnable)
     fun io(runnable: Runnable) = ioExecutor.execute(runnable)

+ 2 - 0
app/src/main/java/com/doverfuelingsolutions/issp/view/MainActivity.kt

@@ -95,6 +95,8 @@ class MainActivity : AppCompatActivity(),
     override fun onDestroy() {
         super.onDestroy()
 
+        dialogFusionLinking?.dismiss()
+        dialogFusionLinking = null
         fragmentRouter.stopFragmentToolbarTimer()
         FusionManager.onFusionStatus = null
         mLoginTokenRefresher.stop()

+ 55 - 5
app/src/main/java/com/doverfuelingsolutions/issp/view/PreferenceActivity.kt

@@ -14,19 +14,22 @@ import androidx.lifecycle.lifecycleScope
 import com.doverfuelingsolutions.issp.R
 import com.doverfuelingsolutions.issp.api.FuelInfoApi
 import com.doverfuelingsolutions.issp.api.SystemApi
-import com.doverfuelingsolutions.issp.bugly.BuglyUtil
+import com.doverfuelingsolutions.issp.api.WayneApiConfig
+import com.doverfuelingsolutions.issp.api.dto.DFSResult
+import com.doverfuelingsolutions.issp.api.ftp.WayneFTPClient
 import com.doverfuelingsolutions.issp.data.GlobalData
 import com.doverfuelingsolutions.issp.databinding.ActivityPreferenceBinding
-import com.doverfuelingsolutions.issp.utils.AppUtil
-import com.doverfuelingsolutions.issp.utils.DFSToastUtil
-import com.doverfuelingsolutions.issp.utils.StringUtil
-import com.doverfuelingsolutions.issp.utils.WindowUtil
+import com.doverfuelingsolutions.issp.utils.*
 import com.doverfuelingsolutions.issp.utils.log.DFSLog
 import com.doverfuelingsolutions.issp.utils.sp.SPKeys
 import com.doverfuelingsolutions.issp.utils.sp.SPUtil
 import com.doverfuelingsolutions.issp.view.fragment.FragmentPreference
 import com.tencent.bugly.beta.Beta
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.launch
+import java.io.File
+import kotlin.coroutines.resume
 
 class PreferenceActivity : AppCompatActivity(),
     View.OnClickListener {
@@ -126,6 +129,7 @@ class PreferenceActivity : AppCompatActivity(),
                 GlobalData.accessTokenExpire.set(0L)
                 LoginActivity.start(this, bundleOf(Pair(LoginActivity.autoLogin, false)))
             }
+            binding.uploadLog -> uploadLog()
         }
     }
 
@@ -189,6 +193,52 @@ class PreferenceActivity : AppCompatActivity(),
         }
     }
 
+    private fun uploadLog() {
+        viewModel.isSubmitting.value = true
+        GlobalScope.launch(Dispatchers.IO) {
+            val localFiles = File(DFSLog.folder).listFiles { file -> file.isFile && file.extension.isEmpty() }
+            if (localFiles.isNullOrEmpty()) {
+                DFSToastUtil.fail(R.string.no_log)
+                return@launch
+            }
+
+            val failList = arrayListOf<String>()
+            val mWayneFTPClient = WayneFTPClient(WayneApiConfig.DOMAIN_DEFAULT.replace("http://", ""), "androidPos", "111111")
+            val remoteFolder = "/isspt/log/${DeviceUtil.generateSerialNumber()}"
+            mWayneFTPClient.connect()
+            localFiles.forEach {
+                val zipFilePath = "${it.absolutePath}.zip"
+                val resultZip = ZipUtils.buildZip(it.absolutePath, zipFilePath, true)
+                if (resultZip) {
+                    val zipFile = File(zipFilePath)
+                    val resultUpload = mWayneFTPClient.uploadFile(zipFile, remoteFolder)
+                    zipFile.delete()
+
+                    if (!resultUpload) failList.add(it.name)
+                } else {
+                    failList.add(it.name)
+                }
+            }
+            mWayneFTPClient.close()
+
+            lifecycleScope.launch {
+                viewModel.isSubmitting.value = false
+            }
+
+            when {
+                localFiles.size == failList.size -> {
+                    DFSToastUtil.fail(R.string.log_upload_fail)
+                }
+                failList.isNotEmpty() -> {
+                    DFSToastUtil.success(StringUtil.get(R.string.log_upload_fail_partly, failList.joinToString(", ")))
+                }
+                else -> {
+                    DFSToastUtil.success(R.string.log_upload_done)
+                }
+            }
+        }
+    }
+
     class PreferenceViewModel : ViewModel() {
         val isSubmitting = MutableLiveData(false)
     }

+ 13 - 3
app/src/main/res/layout/activity_preference.xml

@@ -49,17 +49,27 @@
                 style="@style/Widget.MaterialComponents.Button"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_marginHorizontal="20dp"
+                android:layout_marginHorizontal="15dp"
                 android:enabled="@{!viewModel.isSubmitting}"
                 android:onClick="@{handler}"
                 android:text="@{viewModel.isSubmitting ? @string/in_check_update : @string/check_update}" />
 
+            <Button
+                android:id="@+id/uploadLog"
+                style="@style/Widget.MaterialComponents.Button"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_marginHorizontal="15dp"
+                android:enabled="@{!viewModel.isSubmitting}"
+                android:onClick="@{handler}"
+                android:text="@{viewModel.isSubmitting ? @string/in_update_log : @string/update_log}" />
+
             <Button
                 android:id="@+id/buttonReLogin"
                 style="@style/Widget.MaterialComponents.Button.TextButton"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_marginHorizontal="20dp"
+                android:layout_marginHorizontal="15dp"
                 android:onClick="@{handler}"
                 android:text="@string/re_login" />
 
@@ -68,7 +78,7 @@
                 style="@style/Widget.MaterialComponents.Button.TextButton"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_marginHorizontal="20dp"
+                android:layout_marginHorizontal="15dp"
                 android:enabled="@{!viewModel.isSubmitting}"
                 android:onClick="@{handler}"
                 android:text="@string/click_close" />

+ 10 - 0
app/src/main/res/values/strings.xml

@@ -183,6 +183,16 @@
     <string name="get_fuel_failed">获取油品信息失败</string>
     <string name="get_fuel_success">获取油品信息成功</string>
 
+    <string name="connect_log_server_failed">连接日志服务器失败,请稍后重试</string>
+    <string name="login_log_server_failed">登录到日志服务器失败,请稍后重试</string>
+    <string name="opt_log_server_fail">日志服务器执行操作失败,请稍后重试</string>
+    <string name="no_log">未查询到本地日志</string>
+    <string name="log_upload_fail">日志上传失败,请稍后重试</string>
+    <string name="log_upload_fail_partly">部分日志上传失败:%1$s。如有需要,请稍候重试</string>
+    <string name="log_upload_done">日志上传成功</string>
+    <string name="update_log">上传日志</string>
+    <string name="in_update_log">上传日志中…</string>
+
     <string name="format_date">yyyy-MM-dd HH:mm:ss</string>
 
     <string name="toastSuccess">✔️ %1$s</string>