fix: install OTA updates through PackageInstaller
The refresh button found 2.2.3, downloaded it, and reported "Kurulum reddedildi (imza uyuşmuyor?)" for a build signed with exactly the same platform certificate as the one already running. The signature was never the problem. install() shelled out to `pm install -r` with a path inside the app's private cache. pm does not read the file — it hands the path to the package manager service, which opens it from its own process and SELinux context, where that directory is not readable. The install failed for a reason nobody could see: install() returned a bare boolean, and UpdateHook mapped every false to the one message it had. The pm output that would have named the real error was captured into a StringBuilder and discarded. A PackageInstaller session takes no path. We open the archive ourselves, as the app, and stream it into the session, so no second process ever has to reach our cache. Approach taken from merthankaraman/DriveHub_Dort, which hit the same wall on the same head unit. Consequences worth knowing when reading a future failure: - Committing a session is asynchronous, so the refresh button can only say an install started. OtaInstallResultReceiver has the verdict, and surfaces the package manager's own INSTALL_FAILED_* text rather than guessing. - STATUS_PENDING_USER_ACTION is not an error. The receiver launches the confirmation activity the platform supplies. - The PendingIntent must be MUTABLE or it arrives with no status extras. install() now returns an enum, so a certificate mismatch, an unparseable or wrong-package archive, and a failed session each say what they are. Also checks the archive's package name against ours, which was a genuine gap: the platform key signs every app on this unit, so a certificate match alone never meant the APK was this app. The `pm install -r` exit-code test goes with the shell-out it covered. Nothing in the session path is exercisable off-device. REQUEST_INSTALL_PACKAGES and the receiver are declared in the unstable manifest only — the stable channel has no updater at all, and its merged manifest was checked to confirm it gains neither. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
322ebcc4c0
commit
091d26d6d6
@@ -86,16 +86,42 @@ The version comes from the **asset filename**, not the git tag. The tag can stay
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
|
| `Güncel (2.2.2.0-unstable)` | No release beat the installed version. Check the pre-release tick and the asset filename. |
|
||||||
| `İndirme başarısız` | Release found, download did not complete. Usually no network. |
|
| `İndirme başarısız` | Release found, download did not complete. Usually no network. |
|
||||||
| `Kurulum reddedildi (imza uyuşmuyor?)` | Signed with the wrong key — rebuild and re-sign with `platform.pk8`. |
|
| `Kuruluyor: 2.2.3 — ekrandaki onayı bekleyin` | The session was committed. Not done yet: watch for the platform's own toast, or a confirmation dialog to accept. |
|
||||||
|
| `Kurulum reddedildi: imza uyuşmuyor` | Genuinely the wrong key — rebuild and re-sign with `platform.pk8`. Unlike the old catch-all, this one means what it says. |
|
||||||
|
| `Kurulum reddedildi: APK okunamadı` | The archive does not parse, or it is built for another package. |
|
||||||
|
| `Kurulum başlatılamadı … Log sayfasına bakın` | The session could not be created or committed. `adb logcat -s EVABRP.Update`. |
|
||||||
|
| `Kurulum başarısız: INSTALL_FAILED_…` | From `OtaInstallResultReceiver` — the package manager's own words for why it refused. |
|
||||||
| `Güncelleme kontrolü başarısız` | The check threw. `adb logcat -s EVABRP.Update OtaUpdater` if a cable is available; otherwise the in-app Log page. |
|
| `Güncelleme kontrolü başarısız` | The check threw. `adb logcat -s EVABRP.Update OtaUpdater` if a cable is available; otherwise the in-app Log page. |
|
||||||
|
|
||||||
## Untested
|
## How the install works, and why it is not `pm install`
|
||||||
|
|
||||||
`OtaUpdater.install()` shells out to `/system/bin/pm install -r`, which only a
|
`OtaUpdater.install()` used to shell out to `/system/bin/pm install -r`. On this head unit
|
||||||
platform-signed app may call. That is what this build is, so it should work — but it has
|
that fails every time, and the reason is worth writing down because the symptom pointed
|
||||||
never actually run on the car. If installs are refused with a signature that *does* match,
|
somewhere else entirely.
|
||||||
the fallback is an `ACTION_INSTALL_PACKAGE` intent, which asks the driver to confirm
|
|
||||||
instead of installing silently.
|
The downloaded APK lives in `context.getCacheDir()`. `pm` does not install the file itself
|
||||||
|
— it passes the path to the package manager service, which opens it from *its* process and
|
||||||
|
SELinux context, where an app's private cache is not readable. The install failed, and
|
||||||
|
because `install()` returned a bare boolean, the caller reported the only failure it knew
|
||||||
|
how to name: `Kurulum reddedildi (imza uyuşmuyor?)`. A correctly signed build, refused with
|
||||||
|
a signature error it had not earned. The `pm` output that would have said so was captured
|
||||||
|
and thrown away.
|
||||||
|
|
||||||
|
It now uses the platform `PackageInstaller`: we open the archive ourselves and stream it
|
||||||
|
into a session, so no second process ever has to read our cache. The approach is taken from
|
||||||
|
[merthankaraman/DriveHub_Dort](https://github.com/merthankaraman/DriveHub_Dort), which hit
|
||||||
|
the same wall on the same hardware.
|
||||||
|
|
||||||
|
Two consequences for anyone reading a failure:
|
||||||
|
|
||||||
|
- **Committing a session is not installing.** `commit()` is asynchronous. The refresh button
|
||||||
|
can only report that an install *started*; the verdict arrives at
|
||||||
|
`OtaInstallResultReceiver` and appears as its own toast.
|
||||||
|
- **`STATUS_PENDING_USER_ACTION` is not an error.** The platform is asking the driver to
|
||||||
|
confirm. The receiver launches the confirmation activity the platform hands it.
|
||||||
|
|
||||||
|
Still unverified on the car: whether this build installs silently or raises that
|
||||||
|
confirmation. Either way it installs, which the old path did not.
|
||||||
|
|
||||||
## Why the check is manual
|
## Why the check is manual
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,8 @@
|
|||||||
<string name="notif_channel_desc">Shows vehicle telemetry upload status</string>
|
<string name="notif_channel_desc">Shows vehicle telemetry upload status</string>
|
||||||
<string name="notif_title">ABRP Uploader</string>
|
<string name="notif_title">ABRP Uploader</string>
|
||||||
<string name="notif_action_stop">Stop</string>
|
<string name="notif_action_stop">Stop</string>
|
||||||
|
<string name="ota_install_success">Güncelleme kuruldu — uygulamayı yeniden başlatın</string>
|
||||||
|
<string name="ota_install_failed">Kurulum başarısız: %1$s</string>
|
||||||
<string name="update_check">Güncelleme kontrol et</string>
|
<string name="update_check">Güncelleme kontrol et</string>
|
||||||
<string name="update_checking">Kontrol ediliyor…</string>
|
<string name="update_checking">Kontrol ediliyor…</string>
|
||||||
<string name="about_action">About</string>
|
<string name="about_action">About</string>
|
||||||
|
|||||||
@@ -111,10 +111,8 @@ public class OtaUpdaterTest {
|
|||||||
assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5"));
|
assertArrayEquals(new int[]{1, 0, 5}, OtaUpdater.segments("1.x.5"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
// The `pm install -r` exit-code test that stood here is gone with the shell-out it
|
||||||
public void pmInstallRequiresBothSuccessTextAndZeroExit() {
|
// covered. Installation is now a PackageInstaller session, whose every step needs a real
|
||||||
assertTrue(OtaUpdater.installSucceeded(0, "Success\n"));
|
// PackageManager — there is no pure part of it left to exercise on the JVM. What used to
|
||||||
assertFalse(OtaUpdater.installSucceeded(1, "Success\n"));
|
// be asserted here is now reported by OtaInstallResultReceiver, on the car.
|
||||||
assertFalse(OtaUpdater.installSucceeded(0, "Failure [INSTALL_FAILED]"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Unstable channel only. The stable build has no self-update path, so neither the install
|
||||||
|
permission nor the session result receiver belongs in the main manifest. -->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:sharedUserId="android.uid.system" />
|
android:sharedUserId="android.uid.system">
|
||||||
|
|
||||||
|
<!-- Needed for the PackageInstaller session that replaced `pm install -r`. On this
|
||||||
|
platform-signed build the platform may install without asking; when it does ask,
|
||||||
|
this is what lets the confirmation be raised at all. -->
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
|
|
||||||
|
<application>
|
||||||
|
<receiver
|
||||||
|
android:name=".OtaInstallResultReceiver"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.evsuite.abrp.OTA_INSTALL_RESULT" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageInstaller;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where a {@link PackageInstaller} session reports what it did — UNSTABLE BUILDS ONLY.
|
||||||
|
*
|
||||||
|
* The session commit is asynchronous, so unlike the {@code pm install -r} it replaced, the
|
||||||
|
* install outcome cannot be the return value of the call that started it. It arrives here.
|
||||||
|
*
|
||||||
|
* STATUS_PENDING_USER_ACTION is the case worth understanding: the platform is saying it will
|
||||||
|
* install, but wants the driver to confirm. That happens when the app is not granted a silent
|
||||||
|
* install, and the only correct response is to launch the confirmation activity it hands us.
|
||||||
|
*/
|
||||||
|
public final class OtaInstallResultReceiver extends BroadcastReceiver {
|
||||||
|
|
||||||
|
static final String ACTION_INSTALL_RESULT = "com.evsuite.abrp.OTA_INSTALL_RESULT";
|
||||||
|
|
||||||
|
private static final String TAG = "EVABRP.Update";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReceive(Context context, Intent intent) {
|
||||||
|
if (intent == null) return;
|
||||||
|
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
|
||||||
|
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
||||||
|
Log.i(TAG, "OTA install result: status=" + status + " msg=" + message);
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case PackageInstaller.STATUS_PENDING_USER_ACTION:
|
||||||
|
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
|
||||||
|
if (confirm == null) {
|
||||||
|
toast(context, context.getString(R.string.ota_install_failed, "no confirm intent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
try {
|
||||||
|
context.startActivity(confirm);
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "OTA confirm UI failed: " + t.getMessage());
|
||||||
|
toast(context, context.getString(R.string.ota_install_failed,
|
||||||
|
t.getClass().getSimpleName()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PackageInstaller.STATUS_SUCCESS:
|
||||||
|
toast(context, context.getString(R.string.ota_install_success));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// The status message is the package manager's own words — INSTALL_FAILED_*
|
||||||
|
// and friends. Surfaced verbatim, because guessing at it is what made the
|
||||||
|
// previous failure unreadable.
|
||||||
|
toast(context, context.getString(R.string.ota_install_failed,
|
||||||
|
message != null ? message : "status " + status));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void toast(Context context, String text) {
|
||||||
|
Toast.makeText(context.getApplicationContext(), text, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.evsuite.abrp;
|
package com.evsuite.abrp;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageInstaller;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
@@ -295,27 +298,112 @@ final class OtaUpdater {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean install(Context context, File apk) {
|
/**
|
||||||
if (!signatureMatchesRunningApp(context, apk)) return false;
|
* What {@link #install} did. Every value except {@link #SESSION_STARTED} is a refusal,
|
||||||
try {
|
* and they are distinct because the single "imza uyuşmuyor?" message they used to share
|
||||||
Process process = new ProcessBuilder("/system/bin/pm", "install", "-r",
|
* sent a perfectly well-signed build to the wrong diagnosis.
|
||||||
apk.getAbsolutePath()).redirectErrorStream(true).start();
|
*/
|
||||||
StringBuilder output = new StringBuilder();
|
enum InstallResult {
|
||||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(
|
/** The archive is signed by a different certificate than the running app. */
|
||||||
process.getInputStream(), StandardCharsets.UTF_8))) {
|
SIGNATURE_MISMATCH,
|
||||||
String line;
|
/** The package manager cannot parse the archive, or it is for another package. */
|
||||||
while ((line = reader.readLine()) != null) output.append(line).append('\n');
|
UNREADABLE_ARCHIVE,
|
||||||
|
/** Handed to the platform. The outcome arrives at {@link OtaInstallResultReceiver}. */
|
||||||
|
SESSION_STARTED,
|
||||||
|
/** The session could not be created, written or committed. */
|
||||||
|
SESSION_FAILED
|
||||||
}
|
}
|
||||||
int exitCode = process.waitFor();
|
|
||||||
return installSucceeded(exitCode, output.toString());
|
/**
|
||||||
|
* Hands [apk] to the platform's {@link PackageInstaller}.
|
||||||
|
*
|
||||||
|
* This used to shell out to {@code pm install -r}. That fails on this head unit: the APK
|
||||||
|
* lives in the app's private cache, and the package manager service opens the path from
|
||||||
|
* its own process and SELinux context, where that directory is not readable. The failure
|
||||||
|
* surfaced as a signature error, which it never was.
|
||||||
|
*
|
||||||
|
* A session takes no path. We open the archive ourselves, as the app, and stream the bytes
|
||||||
|
* into the session — so there is no second process that has to be able to read our cache.
|
||||||
|
* The bytes are fully written before commit, which is why the caller may delete the staged
|
||||||
|
* file as soon as this returns.
|
||||||
|
*/
|
||||||
|
static InstallResult install(Context context, File apk) {
|
||||||
|
if (!signatureMatchesRunningApp(context, apk)) return InstallResult.SIGNATURE_MISMATCH;
|
||||||
|
if (!archiveIsThisPackage(context, apk)) return InstallResult.UNREADABLE_ARCHIVE;
|
||||||
|
try {
|
||||||
|
commitSession(context, apk);
|
||||||
|
return InstallResult.SESSION_STARTED;
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.w(TAG, "OTA session install failed", t);
|
||||||
|
return InstallResult.SESSION_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True if the archive parses and declares our own package name.
|
||||||
|
*
|
||||||
|
* A signature match alone does not say the APK is this app: the same platform key signs
|
||||||
|
* every app on the unit, so a correctly signed archive for some other package would pass
|
||||||
|
* the certificate check and then fail deep inside the installer.
|
||||||
|
*/
|
||||||
|
private static boolean archiveIsThisPackage(Context context, File apk) {
|
||||||
|
try {
|
||||||
|
android.content.pm.PackageInfo info = context.getPackageManager()
|
||||||
|
.getPackageArchiveInfo(apk.getAbsolutePath(), 0);
|
||||||
|
if (info == null || info.packageName == null) {
|
||||||
|
Log.w(TAG, "OTA archive cannot be parsed by the package manager");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!context.getPackageName().equals(info.packageName)) {
|
||||||
|
Log.w(TAG, "OTA archive is for " + info.packageName + ", not "
|
||||||
|
+ context.getPackageName());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "OTA archive ok: " + info.packageName + " " + info.versionName);
|
||||||
|
return true;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.w(TAG, "Update install failed", e);
|
Log.w(TAG, "OTA archive check threw: " + e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static boolean installSucceeded(int exitCode, String output) {
|
private static void commitSession(Context context, File apk) throws Exception {
|
||||||
return exitCode == 0 && output.contains("Success");
|
PackageInstaller installer = context.getPackageManager().getPackageInstaller();
|
||||||
|
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(
|
||||||
|
PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= 31) {
|
||||||
|
// Only honoured for an installer the platform already trusts; harmless otherwise,
|
||||||
|
// and this unit is API 28 where the field does not exist at all.
|
||||||
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
|
||||||
|
}
|
||||||
|
int sessionId = installer.createSession(params);
|
||||||
|
PackageInstaller.Session session = null;
|
||||||
|
try {
|
||||||
|
session = installer.openSession(sessionId);
|
||||||
|
try (java.io.InputStream in = new java.io.FileInputStream(apk);
|
||||||
|
java.io.OutputStream out = session.openWrite("base.apk", 0, apk.length())) {
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
int count;
|
||||||
|
while ((count = in.read(buffer)) != -1) out.write(buffer, 0, count);
|
||||||
|
session.fsync(out);
|
||||||
|
}
|
||||||
|
Intent callback = new Intent(context, OtaInstallResultReceiver.class)
|
||||||
|
.setAction(OtaInstallResultReceiver.ACTION_INSTALL_RESULT)
|
||||||
|
.setPackage(context.getPackageName());
|
||||||
|
// MUTABLE: the platform fills in EXTRA_STATUS and, when it wants the driver to
|
||||||
|
// confirm, EXTRA_INTENT. An immutable PendingIntent would arrive empty.
|
||||||
|
PendingIntent pending = PendingIntent.getBroadcast(context, sessionId, callback,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
|
||||||
|
session.commit(pending.getIntentSender());
|
||||||
|
Log.i(TAG, "OTA session " + sessionId + " committed (" + apk.length() + " bytes)");
|
||||||
|
session = null;
|
||||||
|
} finally {
|
||||||
|
// Only reached when commit did not happen; an abandoned session frees its staging.
|
||||||
|
if (session != null) {
|
||||||
|
try { session.abandon(); } catch (Throwable ignored) { }
|
||||||
|
try { session.close(); } catch (Throwable ignored) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,10 +12,15 @@ import java.io.File;
|
|||||||
* This fork publishes its own builds from its own repository, signed with the platform key
|
* This fork publishes its own builds from its own repository, signed with the platform key
|
||||||
* the head unit already trusts, so that audit does not govern here and the seam is live.
|
* the head unit already trusts, so that audit does not govern here and the seam is live.
|
||||||
*
|
*
|
||||||
* Installation goes through {@code pm install -r}, which only a platform-signed build may
|
* Installation goes through the platform {@link android.content.pm.PackageInstaller}, not
|
||||||
* call. {@link OtaUpdater#install} checks the downloaded APK's certificate against the
|
* {@code pm install -r}: that shelled out with a path inside our private cache, which the
|
||||||
* running app's first and refuses a mismatch, so an update signed with any other key is
|
* package manager service cannot read from its own process, and the generic failure was
|
||||||
* deleted rather than installed — the same check that makes an upstream APK unusable here.
|
* reported as a signature mismatch it never was. {@link OtaUpdater#install} still checks the
|
||||||
|
* downloaded APK's certificate against the running app's first and refuses a mismatch, so an
|
||||||
|
* update signed with any other key is deleted rather than installed.
|
||||||
|
*
|
||||||
|
* Committing a session is asynchronous, so the message this returns says an install has been
|
||||||
|
* started, never that one finished. {@link OtaInstallResultReceiver} has the verdict.
|
||||||
*/
|
*/
|
||||||
final class UpdateHook {
|
final class UpdateHook {
|
||||||
|
|
||||||
@@ -78,12 +83,22 @@ final class UpdateHook {
|
|||||||
if (apk == null) return "İndirme başarısız (" + update.versionName + ")";
|
if (apk == null) return "İndirme başarısız (" + update.versionName + ")";
|
||||||
|
|
||||||
// The cached APK is cleared whichever way install goes: a rejected archive must not
|
// The cached APK is cleared whichever way install goes: a rejected archive must not
|
||||||
// linger, and an accepted one has already been copied out by the package manager.
|
// linger, and an accepted one has already been streamed into the installer session.
|
||||||
boolean installed = OtaUpdater.install(app, apk);
|
OtaUpdater.InstallResult result = OtaUpdater.install(app, apk);
|
||||||
if (!apk.delete()) Log.w(TAG, "Could not remove cached OTA APK");
|
if (!apk.delete()) Log.w(TAG, "Could not remove cached OTA APK");
|
||||||
|
|
||||||
return installed
|
switch (result) {
|
||||||
? "Güncellendi: " + update.versionName + " — yeniden başlatın"
|
case SESSION_STARTED:
|
||||||
: "Kurulum reddedildi (imza uyuşmuyor?)";
|
// Deliberately not "Güncellendi": committing a session is not installing. The
|
||||||
|
// platform may still want the driver to confirm, and either way the verdict
|
||||||
|
// arrives at OtaInstallResultReceiver, not here.
|
||||||
|
return "Kuruluyor: " + update.versionName + " — ekrandaki onayı bekleyin";
|
||||||
|
case SIGNATURE_MISMATCH:
|
||||||
|
return "Kurulum reddedildi: imza uyuşmuyor";
|
||||||
|
case UNREADABLE_ARCHIVE:
|
||||||
|
return "Kurulum reddedildi: APK okunamadı";
|
||||||
|
default:
|
||||||
|
return "Kurulum başlatılamadı (" + update.versionName + ") — Log sayfasına bakın";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user