Compare commits

...

2 Commits

Author SHA1 Message Date
yp
197cd6a359 add native UI & remove func force update 2026-07-24 01:06:30 +03:00
yp
7fa7e84c3b add force update 2026-07-23 11:29:27 +03:00
9 changed files with 484 additions and 0 deletions

View File

@@ -145,6 +145,9 @@ void AmneziaApplication::init()
m_coreController.reset(new CoreController(m_vpnConnection, m_settings, m_engine));
m_marketplaceUpdateController.reset(new MarketplaceUpdateController());
m_marketplaceUpdateController->start();
m_engine->addImportPath("qrc:/ui/qml/Modules/");
if (m_parser.isSet(m_optImport)) {

View File

@@ -15,6 +15,7 @@
#include "core/controllers/coreController.h"
#include "secureQSettings.h"
#include "ui/controllers/marketplaceUpdateController.h"
#include "vpnConnection.h"
#include "ui/models/containerProps.h"
#include "ui/models/protocolProps.h"
@@ -56,6 +57,7 @@ private:
SecureQSettings* m_settings;
QScopedPointer<CoreController> m_coreController;
QScopedPointer<MarketplaceUpdateController> m_marketplaceUpdateController;
QSharedPointer<ContainerProps> m_containerProps;
QSharedPointer<ProtocolProps> m_protocolProps;

View File

@@ -3,6 +3,13 @@ package org.amnezia.vpn
import android.Manifest
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.app.Dialog
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.view.Gravity
import android.widget.Button
import android.widget.LinearLayout
import android.widget.TextView
import android.app.NotificationManager
import android.content.ActivityNotFoundException
import android.content.BroadcastReceiver
@@ -100,6 +107,8 @@ class AmneziaActivity : QtActivity() {
private var pendingOpenFileUri: String? = null
private var openFileDeliveryScheduled = false
private var updateCoverDialog: Dialog? = null
private val vpnServiceEventHandler: Handler by lazy(NONE) {
object : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: Message) {
@@ -487,6 +496,116 @@ class AmneziaActivity : QtActivity() {
super.onDestroy()
}
fun showUpdateCover() {
runOnUiThread {
if (isFinishing || isDestroyed || updateCoverDialog != null) return@runOnUiThread
val dialog = Dialog(this, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
dialog.setCancelable(false)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(0xFF0E0E11.toInt())
}
dialog.setContentView(root)
dialog.show()
updateCoverDialog = dialog
}
}
fun hideUpdateCover() {
runOnUiThread {
updateCoverDialog?.dismiss()
updateCoverDialog = null
}
}
fun showUpdatePrompt(title: String, message: String, updateTitle: String, skipTitle: String, storeUrl: String) {
runOnUiThread {
if (isFinishing || isDestroyed) return@runOnUiThread
val dialog = updateCoverDialog ?: Dialog(this, android.R.style.Theme_Black_NoTitleBar_Fullscreen).also {
it.setCancelable(false)
it.show()
updateCoverDialog = it
}
val density = resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
setBackgroundColor(0xFF0E0E11.toInt())
setPadding(dp(32), dp(32), dp(32), dp(32))
}
val titleView = TextView(this).apply {
text = title
textSize = 22f
setTextColor(0xFFFFFFFF.toInt())
gravity = Gravity.CENTER
typeface = Typeface.create(typeface, Typeface.BOLD)
}
val messageView = TextView(this).apply {
text = message
textSize = 16f
setTextColor(0xFFC7C8CB.toInt())
gravity = Gravity.CENTER
setPadding(0, dp(16), 0, dp(28))
}
val updateButton = Button(this).apply {
text = updateTitle
isAllCaps = false
textSize = 17f
setTextColor(0xFF0E0E11.toInt())
stateListAnimator = null
background = GradientDrawable().apply {
cornerRadius = dp(12).toFloat()
setColor(0xFFFBB26A.toInt())
}
setOnClickListener {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(storeUrl)))
} catch (e: ActivityNotFoundException) {
Log.w(TAG, "open store failed: ${e.message}")
}
hideUpdateCover()
}
}
val skipButton = Button(this).apply {
text = skipTitle
isAllCaps = false
textSize = 17f
setTextColor(0xFFD7D8DB.toInt())
stateListAnimator = null
background = GradientDrawable().apply {
cornerRadius = dp(12).toFloat()
setColor(0x00000000)
setStroke(dp(1), 0xFF2C2D30.toInt())
}
setOnClickListener { hideUpdateCover() }
}
val updateParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52)
).apply { topMargin = dp(8) }
val skipParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, dp(52)
).apply { topMargin = dp(12) }
root.addView(titleView)
root.addView(messageView)
root.addView(updateButton, updateParams)
root.addView(skipButton, skipParams)
dialog.setContentView(root)
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
Log.d(TAG, "Process activity result, code: ${actionCodeToString(requestCode)}, " +
"resultCode: $resultCode, data: $data")

View File

@@ -154,6 +154,28 @@ void AndroidController::resetLastServer(int serverIndex)
callActivityMethod("resetLastServer", "(I)V", serverIndex);
}
void AndroidController::showUpdateCover()
{
callActivityMethod("showUpdateCover", "()V");
}
void AndroidController::hideUpdateCover()
{
callActivityMethod("hideUpdateCover", "()V");
}
void AndroidController::showUpdatePrompt(const QString &title, const QString &message, const QString &updateTitle,
const QString &skipTitle, const QString &storeUrl)
{
callActivityMethod("showUpdatePrompt",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V",
QJniObject::fromString(title).object<jstring>(),
QJniObject::fromString(message).object<jstring>(),
QJniObject::fromString(updateTitle).object<jstring>(),
QJniObject::fromString(skipTitle).object<jstring>(),
QJniObject::fromString(storeUrl).object<jstring>());
}
void AndroidController::saveFile(const QString &fileName, const QString &data)
{
callActivityMethod("saveFile", "(Ljava/lang/String;Ljava/lang/String;)V",

View File

@@ -56,6 +56,11 @@ public:
bool requestAuthentication();
void sendTouch(float x, float y);
void showUpdateCover();
void hideUpdateCover();
void showUpdatePrompt(const QString &title, const QString &message, const QString &updateTitle,
const QString &skipTitle, const QString &storeUrl);
static bool initLogging();
static void messageHandler(QtMsgType type, const QMessageLogContext &context, const QString &message);

View File

@@ -80,6 +80,11 @@ public:
void requestInetAccess();
bool isTestFlight();
void showUpdateCover();
void hideUpdateCover();
void showUpdatePrompt(const QString &title, const QString &message, const QString &updateTitle,
const QString &skipTitle, const QString &storeUrl);
signals:
void connectionStateChanged(Vpn::ConnectionState state);
void bytesChanged(quint64 receivedBytes, quint64 sentBytes);

View File

@@ -1200,3 +1200,138 @@ bool IosController::isTestFlight() {
NSURL *receiptURL = [[NSBundle mainBundle] appStoreReceiptURL];
return receiptURL && [[receiptURL lastPathComponent] isEqualToString:@"sandboxReceipt"];
}
#if !MACOS_NE
static UIWindow *s_updateCoverWindow = nil;
static UIWindowScene *activeWindowScene() {
UIWindowScene *fallback = nil;
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) {
continue;
}
fallback = (UIWindowScene *)scene;
if (scene.activationState == UISceneActivationStateForegroundActive) {
return (UIWindowScene *)scene;
}
}
return fallback;
}
#endif
void IosController::showUpdateCover() {
#if !MACOS_NE
void (^build)(void) = ^{
if (s_updateCoverWindow) {
return;
}
UIWindowScene *scene = activeWindowScene();
if (!scene) {
return;
}
UIWindow *win = [[UIWindow alloc] initWithWindowScene:scene];
win.windowLevel = UIWindowLevelAlert + 1;
UIViewController *vc = [[[UIViewController alloc] init] autorelease];
vc.view.backgroundColor = [UIColor colorWithRed:0.055 green:0.055 blue:0.063 alpha:1.0];
win.rootViewController = vc;
[win makeKeyAndVisible];
s_updateCoverWindow = win;
};
if ([NSThread isMainThread]) {
build();
} else {
dispatch_sync(dispatch_get_main_queue(), build);
}
#endif
}
void IosController::hideUpdateCover() {
#if !MACOS_NE
dispatch_async(dispatch_get_main_queue(), ^{
if (!s_updateCoverWindow) {
return;
}
s_updateCoverWindow.hidden = YES;
[s_updateCoverWindow release];
s_updateCoverWindow = nil;
});
#endif
}
void IosController::showUpdatePrompt(const QString &title, const QString &message, const QString &updateTitle,
const QString &skipTitle, const QString &storeUrl) {
#if !MACOS_NE
NSString *nsTitle = title.toNSString();
NSString *nsMessage = message.toNSString();
NSString *nsUpdate = updateTitle.toNSString();
NSString *nsSkip = skipTitle.toNSString();
NSString *nsUrl = storeUrl.toNSString();
dispatch_async(dispatch_get_main_queue(), ^{
if (!s_updateCoverWindow) {
return;
}
UIViewController *vc = s_updateCoverWindow.rootViewController;
void (^dismissCover)(void) = ^{
s_updateCoverWindow.hidden = YES;
[s_updateCoverWindow release];
s_updateCoverWindow = nil;
};
UILabel *titleLabel = [[[UILabel alloc] init] autorelease];
titleLabel.text = nsTitle;
titleLabel.font = [UIFont boldSystemFontOfSize:22];
titleLabel.textColor = [UIColor whiteColor];
titleLabel.textAlignment = NSTextAlignmentCenter;
titleLabel.numberOfLines = 0;
UILabel *messageLabel = [[[UILabel alloc] init] autorelease];
messageLabel.text = nsMessage;
messageLabel.font = [UIFont systemFontOfSize:16];
messageLabel.textColor = [UIColor colorWithWhite:0.78 alpha:1.0];
messageLabel.textAlignment = NSTextAlignmentCenter;
messageLabel.numberOfLines = 0;
UIButton *updateButton = [UIButton buttonWithType:UIButtonTypeSystem];
[updateButton setTitle:nsUpdate forState:UIControlStateNormal];
[updateButton setTitleColor:[UIColor blackColor] forState:UIControlStateNormal];
updateButton.backgroundColor = [UIColor colorWithRed:1.0 green:0.6 blue:0.0 alpha:1.0];
updateButton.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
updateButton.layer.cornerRadius = 12;
[updateButton.heightAnchor constraintEqualToConstant:52].active = YES;
[updateButton addAction:[UIAction actionWithHandler:^(__kindof UIAction *action) {
NSURL *url = [NSURL URLWithString:nsUrl];
if (url) {
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
dismissCover();
}] forControlEvents:UIControlEventTouchUpInside];
UIButton *skipButton = [UIButton buttonWithType:UIButtonTypeSystem];
[skipButton setTitle:nsSkip forState:UIControlStateNormal];
[skipButton setTitleColor:[UIColor colorWithWhite:0.7 alpha:1.0] forState:UIControlStateNormal];
skipButton.titleLabel.font = [UIFont systemFontOfSize:17];
[skipButton.heightAnchor constraintEqualToConstant:44].active = YES;
[skipButton addAction:[UIAction actionWithHandler:^(__kindof UIAction *action) {
dismissCover();
}] forControlEvents:UIControlEventTouchUpInside];
UIStackView *stack = [[[UIStackView alloc] initWithArrangedSubviews:@[titleLabel, messageLabel, updateButton, skipButton]] autorelease];
stack.axis = UILayoutConstraintAxisVertical;
stack.spacing = 16;
stack.translatesAutoresizingMaskIntoConstraints = NO;
[stack setCustomSpacing:28 afterView:messageLabel];
[vc.view addSubview:stack];
[NSLayoutConstraint activateConstraints:@[
[stack.centerYAnchor constraintEqualToAnchor:vc.view.centerYAnchor],
[stack.leadingAnchor constraintEqualToAnchor:vc.view.leadingAnchor constant:32],
[stack.trailingAnchor constraintEqualToAnchor:vc.view.trailingAnchor constant:-32]
]];
});
#else
Q_UNUSED(title) Q_UNUSED(message) Q_UNUSED(updateTitle) Q_UNUSED(skipTitle) Q_UNUSED(storeUrl)
#endif
}

View File

@@ -0,0 +1,163 @@
#include "marketplaceUpdateController.h"
#include <QDebug>
#include "version.h"
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QLocale>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QVersionNumber>
#endif
#if defined(Q_OS_IOS)
#include "platforms/ios/ios_controller.h"
#endif
#if defined(Q_OS_ANDROID)
#include "platforms/android/android_controller.h"
#endif
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
namespace
{
#if defined(Q_OS_IOS)
constexpr auto kIosBundleId = "org.amnezia.AmneziaVPN";
constexpr auto kIosStoreUrlFallback = "itms-apps://itunes.apple.com/app/id1600529900";
#else
constexpr auto kAndroidPackage = "org.amnezia.vpn";
constexpr auto kAndroidStoreUrl = "https://play.google.com/store/apps/details?id=org.amnezia.vpn";
#endif
} // namespace
#endif
MarketplaceUpdateController::MarketplaceUpdateController(QObject *parent) : QObject(parent)
{
}
void MarketplaceUpdateController::start()
{
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
// Cover the app immediately so the store UI is not visible before the check
// resolves (avoids a flash of the main window before the update screen).
showCover();
const QUrl url = versionSourceUrl();
if (!url.isValid()) {
hideCover();
return;
}
QNetworkRequest request(url);
request.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysNetwork);
request.setHeader(QNetworkRequest::UserAgentHeader, QByteArrayLiteral("AmneziaVPN"));
QNetworkReply *reply = m_nam.get(request);
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "[MarketplaceUpdate] network error:" << reply->errorString();
hideCover();
return;
}
QString version;
QString storeUrl;
if (!parseStoreVersion(reply->readAll(), version, storeUrl) || version.isEmpty()) {
qWarning() << "[MarketplaceUpdate] could not determine store version";
hideCover();
return;
}
const auto current = QVersionNumber::fromString(QString(APP_VERSION)).normalized();
const auto store = QVersionNumber::fromString(version).normalized();
qInfo() << "[MarketplaceUpdate] current:" << current.toString() << "store:" << store.toString();
if (store > current) {
showUpdatePrompt(storeUrl);
} else {
hideCover();
}
});
#endif
}
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
QUrl MarketplaceUpdateController::versionSourceUrl() const
{
#if defined(Q_OS_IOS)
const QString country = QLocale::system().name().section('_', 1, 1).toLower();
QString url = QStringLiteral("https://itunes.apple.com/lookup?bundleId=%1").arg(kIosBundleId);
if (!country.isEmpty()) {
url += QStringLiteral("&country=%1").arg(country);
}
return QUrl(url);
#else
return QUrl(QStringLiteral("https://play.google.com/store/apps/details?id=%1&hl=en&gl=US").arg(kAndroidPackage));
#endif
}
bool MarketplaceUpdateController::parseStoreVersion(const QByteArray &body, QString &version, QString &storeUrl)
{
#if defined(Q_OS_IOS)
const auto results = QJsonDocument::fromJson(body).object().value("results").toArray();
if (results.isEmpty()) {
return false;
}
const auto first = results.first().toObject();
version = first.value("version").toString();
storeUrl = first.value("trackViewUrl").toString();
if (storeUrl.isEmpty()) {
storeUrl = QString::fromLatin1(kIosStoreUrlFallback);
}
return !version.isEmpty();
#else
const QString html = QString::fromUtf8(body);
static const QRegularExpression re(QStringLiteral("\\[\\[\\[\"(\\d+\\.\\d+(?:\\.\\d+){0,2})\"\\]\\]"));
const auto match = re.match(html);
if (match.hasMatch()) {
version = match.captured(1);
}
storeUrl = QString::fromLatin1(kAndroidStoreUrl);
return !version.isEmpty();
#endif
}
void MarketplaceUpdateController::showCover()
{
#if defined(Q_OS_IOS)
IosController::Instance()->showUpdateCover();
#else
AndroidController::instance()->showUpdateCover();
#endif
}
void MarketplaceUpdateController::hideCover()
{
#if defined(Q_OS_IOS)
IosController::Instance()->hideUpdateCover();
#else
AndroidController::instance()->hideUpdateCover();
#endif
}
void MarketplaceUpdateController::showUpdatePrompt(const QString &storeUrl)
{
const QString title = tr("Update available");
const QString message = tr("A new version of AmneziaVPN is available.");
const QString updateTitle = tr("Update");
const QString skipTitle = tr("Skip");
#if defined(Q_OS_IOS)
IosController::Instance()->showUpdatePrompt(title, message, updateTitle, skipTitle, storeUrl);
#else
AndroidController::instance()->showUpdatePrompt(title, message, updateTitle, skipTitle, storeUrl);
#endif
}
#endif

View File

@@ -0,0 +1,30 @@
#ifndef MARKETPLACEUPDATECONTROLLER_H
#define MARKETPLACEUPDATECONTROLLER_H
#include <QNetworkAccessManager>
#include <QObject>
#include <QUrl>
class MarketplaceUpdateController : public QObject
{
Q_OBJECT
public:
explicit MarketplaceUpdateController(QObject *parent = nullptr);
public slots:
void start();
private:
#if defined(Q_OS_IOS) || defined(Q_OS_ANDROID)
QUrl versionSourceUrl() const;
bool parseStoreVersion(const QByteArray &body, QString &version, QString &storeUrl);
void showCover();
void hideCover();
void showUpdatePrompt(const QString &storeUrl);
QNetworkAccessManager m_nam;
#endif
};
#endif // MARKETPLACEUPDATECONTROLLER_H